diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 311a719..5089079 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -16,6 +16,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/providers/settingstore" "github.com/lmika/audax/internal/dynamo-browse/providers/workspacestore" "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" + "github.com/lmika/audax/internal/dynamo-browse/services/jobs" keybindings_service "github.com/lmika/audax/internal/dynamo-browse/services/keybindings" "github.com/lmika/audax/internal/dynamo-browse/services/tables" workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" @@ -94,10 +95,12 @@ func main() { tableService := tables.NewService(dynamoProvider, settingStore) workspaceService := workspaces_service.NewService(resultSetSnapshotStore) itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo) + jobsService := jobs.NewService(eventBus) state := controllers.NewState() - tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, eventBus, *flagTable) - tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController, settingStore) + jobsController := controllers.NewJobsController(jobsService, eventBus, false) + tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, jobsController, eventBus, *flagTable) + tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore) columnsController := controllers.NewColumnsController(eventBus) exportController := controllers.NewExportController(state, columnsController) settingsController := controllers.NewSettingsController(settingStore) @@ -114,6 +117,7 @@ func main() { columnsController, exportController, settingsController, + jobsController, itemRendererService, commandController, keyBindingController, @@ -122,9 +126,11 @@ func main() { // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. osstyle.DetectCurrentScheme() - + p := tea.NewProgram(model, tea.WithAltScreen()) + jobsController.SetMessageSender(p.Send) + log.Println("launching") if err := p.Start(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 65cc0ae..994c748 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -57,14 +57,20 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms return command(ctx, tokens[1:]) } -func (c *CommandController) Alias(commandName string) Command { +func (c *CommandController) Alias(commandName string, aliasArgs []string) Command { return func(ctx ExecContext, args []string) tea.Msg { command := c.lookupCommand(commandName) if command == nil { return events.Error(errors.New("no such command: " + commandName)) } - return command(ctx, args) + var allArgs []string + if len(aliasArgs) > 0 { + allArgs = append(append([]string{}, aliasArgs...), args...) + } else { + allArgs = args + } + return command(ctx, allArgs) } } diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index e521a95..9f6bb26 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -29,7 +29,13 @@ func PromptForInput(prompt string, onDone func(value string) tea.Msg) tea.Msg { } } -func Confirm(prompt string, onYes func() tea.Msg) tea.Msg { +func Confirm(prompt string, onResult func(yes bool) tea.Msg) tea.Msg { + return PromptForInput(prompt, func(value string) tea.Msg { + return onResult(value == "y") + }) +} + +func ConfirmYes(prompt string, onYes func() tea.Msg) tea.Msg { return PromptForInput(prompt, func(value string) tea.Msg { if value == "y" { return onYes() diff --git a/internal/common/ui/events/jobs.go b/internal/common/ui/events/jobs.go new file mode 100644 index 0000000..07dde38 --- /dev/null +++ b/internal/common/ui/events/jobs.go @@ -0,0 +1,6 @@ +package events + +type ForegroundJobUpdate struct { + JobRunning bool + JobStatus string +} diff --git a/internal/dynamo-browse/controllers/jobbuilder.go b/internal/dynamo-browse/controllers/jobbuilder.go new file mode 100644 index 0000000..25f8e9b --- /dev/null +++ b/internal/dynamo-browse/controllers/jobbuilder.go @@ -0,0 +1,87 @@ +package controllers + +import ( + "context" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/common/ui/events" +) + +func NewJob[T any](jc *JobsController, description string, job func(ctx context.Context) (T, error)) JobBuilder[T] { + return JobBuilder[T]{jc: jc, description: description, job: job} +} + +type JobBuilder[T any] struct { + jc *JobsController + description string + job func(ctx context.Context) (T, error) + onDone func(res T) tea.Msg + onErr func(err error) tea.Msg + onEither func(res T, err error) tea.Msg +} + +func (jb JobBuilder[T]) OnDone(fn func(res T) tea.Msg) JobBuilder[T] { + newJb := jb + newJb.onDone = fn + return newJb +} + +func (jb JobBuilder[T]) OnErr(fn func(err error) tea.Msg) JobBuilder[T] { + newJb := jb + newJb.onErr = fn + return newJb +} + +func (jb JobBuilder[T]) OnEither(fn func(res T, err error) tea.Msg) JobBuilder[T] { + newJb := jb + newJb.onEither = fn + return newJb +} + +func (jb JobBuilder[T]) Submit() tea.Msg { + if jb.jc.immediate { + return jb.executeJob(context.Background()) + } + return jb.doSubmit() +} + +func (jb JobBuilder[T]) executeJob(ctx context.Context) tea.Msg { + res, err := jb.job(ctx) + + if jb.onEither != nil { + return jb.onEither(res, err) + } else if err == nil { + return jb.onDone(res) + } else { + if jb.onErr != nil { + return jb.onErr(err) + } else { + return events.Error(err) + } + } +} + +func (jb JobBuilder[T]) doSubmit() tea.Msg { + jb.jc.service.SubmitForegroundJob(func(ctx context.Context) { + msg := jb.executeJob(ctx) + + jb.jc.msgSender(msg) + + if _, isForegroundJobUpdate := msg.(events.ForegroundJobUpdate); !isForegroundJobUpdate { + // Likely another job was scheduled so don't indicate that no jobs are running. + jb.jc.msgSender(events.ForegroundJobUpdate{ + JobRunning: false, + JobStatus: "", + }) + } + }, func(msg string) { + jb.jc.msgSender(events.ForegroundJobUpdate{ + JobRunning: true, + JobStatus: jb.description + " " + msg, + }) + }) + + return events.ForegroundJobUpdate{ + JobRunning: true, + JobStatus: jb.description, + } +} diff --git a/internal/dynamo-browse/controllers/jobs.go b/internal/dynamo-browse/controllers/jobs.go new file mode 100644 index 0000000..b358426 --- /dev/null +++ b/internal/dynamo-browse/controllers/jobs.go @@ -0,0 +1,38 @@ +package controllers + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/services/jobs" + bus "github.com/lmika/events" +) + +type JobsController struct { + service *jobs.Services + msgSender func(msg tea.Msg) + immediate bool +} + +func NewJobsController(service *jobs.Services, bus *bus.Bus, immediate bool) *JobsController { + jc := &JobsController{ + service: service, + immediate: immediate, + } + + return jc +} + +func (js *JobsController) SetMessageSender(msgSender func(msg tea.Msg)) { + js.msgSender = msgSender +} + +func (js *JobsController) CancelRunningJob(ifNoJobsRunning func() tea.Msg) tea.Msg { + hasCancelled := js.service.CancelForegroundJob() + if hasCancelled { + return events.ForegroundJobUpdate{ + JobRunning: true, + JobStatus: "Cancelling running job…", + } + } + return ifNoJobsRunning() +} diff --git a/internal/dynamo-browse/controllers/keybinding.go b/internal/dynamo-browse/controllers/keybinding.go index a06492d..2d62b65 100644 --- a/internal/dynamo-browse/controllers/keybinding.go +++ b/internal/dynamo-browse/controllers/keybinding.go @@ -27,7 +27,7 @@ func (kb *KeyBindingController) Rebind(bindingName string, newKey string, force var keyAlreadyBoundErr keybindings.KeyAlreadyBoundError if errors.As(err, &keyAlreadyBoundErr) { promptMsg := fmt.Sprintf("Key '%v' already bound to '%v'. Continue? ", keyAlreadyBoundErr.Key, keyAlreadyBoundErr.ExistingBindingName) - return events.Confirm(promptMsg, func() tea.Msg { + return events.ConfirmYes(promptMsg, func() tea.Msg { err := kb.service.Rebind(bindingName, newKey, true) if err != nil { return events.Error(err) diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 9e36567..3265edd 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -29,10 +29,19 @@ const ( resultSetUpdateTouch ) +type MarkOp int + +const ( + MarkOpMark MarkOp = iota + MarkOpUnmark + MarkOpToggle +) + type TableReadController struct { tableService TableReadService workspaceService *workspaces.ViewSnapshotService itemRendererService *itemrenderer.Service + jobController *JobsController eventBus *bus.Bus tableName string loadFromLastView bool @@ -48,6 +57,7 @@ func NewTableReadController( tableService TableReadService, workspaceService *workspaces.ViewSnapshotService, itemRendererService *itemrenderer.Service, + jobController *JobsController, eventBus *bus.Bus, tableName string, ) *TableReadController { @@ -56,6 +66,7 @@ func NewTableReadController( tableService: tableService, workspaceService: workspaceService, itemRendererService: itemRendererService, + jobController: jobController, eventBus: eventBus, tableName: tableName, mutex: new(sync.Mutex), @@ -79,57 +90,67 @@ func (c *TableReadController) Init() tea.Msg { } func (c *TableReadController) ListTables() tea.Msg { - tables, err := c.tableService.ListTables(context.Background()) - if err != nil { - return events.Error(err) - } + return NewJob(c.jobController, "Listing tables…", func(ctx context.Context) (any, error) { + tables, err := c.tableService.ListTables(context.Background()) + if err != nil { + return nil, err + } + return tables, nil + }).OnDone(func(res any) tea.Msg { + return PromptForTableMsg{ + Tables: res.([]string), + OnSelected: func(tableName string) tea.Msg { + if tableName == "" { + return events.StatusMsg("No table selected") + } - return PromptForTableMsg{ - Tables: tables, - OnSelected: func(tableName string) tea.Msg { - return c.ScanTable(tableName) - }, - } + return c.ScanTable(tableName) + }, + } + }).Submit() } func (c *TableReadController) ScanTable(name string) tea.Msg { - ctx := context.Background() + return NewJob(c.jobController, "Scanning…", func(ctx context.Context) (*models.ResultSet, error) { + tableInfo, err := c.tableService.Describe(ctx, name) + if err != nil { + return nil, errors.Wrapf(err, "cannot describe %v", c.tableName) + } - tableInfo, err := c.tableService.Describe(ctx, name) - if err != nil { - return events.Error(errors.Wrapf(err, "cannot describe %v", c.tableName)) - } + resultSet, err := c.tableService.Scan(ctx, tableInfo) + if resultSet != nil { + resultSet = c.tableService.Filter(resultSet, c.state.Filter()) + } - resultSet, err := c.tableService.Scan(ctx, tableInfo) - if err != nil { - return events.Error(err) - } - resultSet = c.tableService.Filter(resultSet, c.state.Filter()) - - return c.setResultSetAndFilter(resultSet, c.state.Filter(), true, resultSetUpdateInit) + return resultSet, err + }).OnEither(c.handleResultSetFromJobResult(c.state.Filter(), true, resultSetUpdateInit)).Submit() } func (c *TableReadController) PromptForQuery() tea.Msg { return events.PromptForInputMsg{ Prompt: "query: ", OnDone: func(value string) tea.Msg { - return c.runQuery(c.state.ResultSet().TableInfo, value, "", true) + resultSet := c.state.ResultSet() + if resultSet == nil { + return events.StatusMsg("Result-set is nil") + } + + return c.runQuery(resultSet.TableInfo, value, "", true) }, } } func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFilter string, pushSnapshot bool) tea.Msg { if query == "" { - newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, nil) - if err != nil { - return events.Error(err) - } + return NewJob(c.jobController, "Scanning…", func(ctx context.Context) (*models.ResultSet, error) { + newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, nil) - if newFilter != "" { - newResultSet = c.tableService.Filter(newResultSet, newFilter) - } + if newResultSet != nil && newFilter != "" { + newResultSet = c.tableService.Filter(newResultSet, newFilter) + } - return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery) + return newResultSet, err + }).OnEither(c.handleResultSetFromJobResult(newFilter, pushSnapshot, resultSetUpdateQuery)).Submit() } expr, err := queryexpr.Parse(query) @@ -138,15 +159,14 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi } return c.doIfNoneDirty(func() tea.Msg { - newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, expr) - if err != nil { - return events.Error(err) - } + return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) { + newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, expr) - if newFilter != "" { - newResultSet = c.tableService.Filter(newResultSet, newFilter) - } - return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery) + if newFilter != "" && newResultSet != nil { + newResultSet = c.tableService.Filter(newResultSet, newFilter) + } + return newResultSet, err + }).OnEither(c.handleResultSetFromJobResult(newFilter, pushSnapshot, resultSetUpdateQuery)).Submit() }) } @@ -175,19 +195,19 @@ func (c *TableReadController) doIfNoneDirty(cmd tea.Cmd) tea.Msg { func (c *TableReadController) Rescan() tea.Msg { return c.doIfNoneDirty(func() tea.Msg { resultSet := c.state.ResultSet() - return c.doScan(context.Background(), resultSet, resultSet.Query, true, resultSetUpdateRescan) + return c.doScan(resultSet, resultSet.Query, true, resultSetUpdateRescan) }) } -func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable, pushBackstack bool, op resultSetUpdateOp) tea.Msg { - newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query) - if err != nil { - return events.Error(err) - } +func (c *TableReadController) doScan(resultSet *models.ResultSet, query models.Queryable, pushBackstack bool, op resultSetUpdateOp) tea.Msg { + return NewJob(c.jobController, "Rescan…", func(ctx context.Context) (*models.ResultSet, error) { + newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query) + if newResultSet != nil { + newResultSet = c.tableService.Filter(newResultSet, c.state.Filter()) + } - newResultSet = c.tableService.Filter(newResultSet, c.state.Filter()) - - return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack, op) + return newResultSet, err + }).OnEither(c.handleResultSetFromJobResult(c.state.Filter(), pushBackstack, op)).Submit() } func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool, op resultSetUpdateOp) tea.Msg { @@ -204,10 +224,21 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, return c.state.buildNewResultSetMessage("") } -func (c *TableReadController) Unmark() tea.Msg { +func (c *TableReadController) Mark(op MarkOp) tea.Msg { c.state.withResultSet(func(resultSet *models.ResultSet) { for i := range resultSet.Items() { - resultSet.SetMark(i, false) + if resultSet.Hidden(i) { + continue + } + + switch op { + case MarkOpMark: + resultSet.SetMark(i, true) + case MarkOpUnmark: + resultSet.SetMark(i, false) + case MarkOpToggle: + resultSet.SetMark(i, !resultSet.Marked(i)) + } } }) return ResultSetUpdated{} @@ -218,13 +249,37 @@ func (c *TableReadController) Filter() tea.Msg { Prompt: "filter: ", OnDone: func(value string) tea.Msg { resultSet := c.state.ResultSet() - newResultSet := c.tableService.Filter(resultSet, value) + if resultSet == nil { + return events.StatusMsg("Result-set is nil") + } - return c.setResultSetAndFilter(newResultSet, value, true, resultSetUpdateFilter) + return NewJob(c.jobController, "Applying Filter…", func(ctx context.Context) (*models.ResultSet, error) { + newResultSet := c.tableService.Filter(resultSet, value) + return newResultSet, nil + }).OnEither(c.handleResultSetFromJobResult(value, true, resultSetUpdateFilter)).Submit() }, } } +func (c *TableReadController) handleResultSetFromJobResult(filter string, pushbackStack bool, op resultSetUpdateOp) func(newResultSet *models.ResultSet, err error) tea.Msg { + return func(newResultSet *models.ResultSet, err error) tea.Msg { + if err == nil { + return c.setResultSetAndFilter(newResultSet, filter, pushbackStack, op) + } + + var partialResultsErr models.PartialResultsError + if errors.As(err, &partialResultsErr) { + return events.Confirm(applyToN("View the ", len(newResultSet.Items()), "item", "items", " returned so far? "), func(yes bool) tea.Msg { + if yes { + return c.setResultSetAndFilter(newResultSet, filter, pushbackStack, op) + } + return events.StatusMsg("Operation cancelled") + }) + } + return events.Error(err) + } +} + func (c *TableReadController) ViewBack() tea.Msg { viewSnapshot, err := c.workspaceService.ViewBack() if err != nil { @@ -252,11 +307,15 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi currentResultSet := c.state.ResultSet() if currentResultSet == nil { - tableInfo, err := c.tableService.Describe(context.Background(), viewSnapshot.TableName) - if err != nil { - return events.Error(err) - } - return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false) + return NewJob(c.jobController, "Fetching table info…", func(ctx context.Context) (*models.TableInfo, error) { + tableInfo, err := c.tableService.Describe(context.Background(), viewSnapshot.TableName) + if err != nil { + return nil, err + } + return tableInfo, nil + }).OnDone(func(tableInfo *models.TableInfo) tea.Msg { + return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false) + }).Submit() } var currentQueryExpr string @@ -265,23 +324,24 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi } if viewSnapshot.TableName == currentResultSet.TableInfo.Name && viewSnapshot.Query == currentQueryExpr { - log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter) - - newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter) - return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false, resultSetUpdateSnapshotRestore) + return NewJob(c.jobController, "Applying filter…", func(ctx context.Context) (*models.ResultSet, error) { + return c.tableService.Filter(currentResultSet, viewSnapshot.Filter), nil + }).OnEither(c.handleResultSetFromJobResult(viewSnapshot.Filter, false, resultSetUpdateSnapshotRestore)).Submit() } - tableInfo := currentResultSet.TableInfo - if viewSnapshot.TableName != currentResultSet.TableInfo.Name { - tableInfo, err = c.tableService.Describe(context.Background(), viewSnapshot.TableName) - if err != nil { - return events.Error(err) + return NewJob(c.jobController, "Running query…", func(ctx context.Context) (tea.Msg, error) { + tableInfo := currentResultSet.TableInfo + if viewSnapshot.TableName != currentResultSet.TableInfo.Name { + tableInfo, err = c.tableService.Describe(context.Background(), viewSnapshot.TableName) + if err != nil { + return nil, err + } } - } - log.Printf("backstack: running query: table = '%v', query = '%v', filter = '%v'", - tableInfo.Name, viewSnapshot.Query, viewSnapshot.Filter) - return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false) + return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false), nil + }).OnDone(func(m tea.Msg) tea.Msg { + return m + }).Submit() } func (c *TableReadController) CopyItemToClipboard(idx int) tea.Msg { diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 69dd727..8954daf 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -16,14 +16,22 @@ import ( type TableWriteController struct { state *State tableService *tables.Service + jobController *JobsController tableReadControllers *TableReadController settingProvider SettingsProvider } -func NewTableWriteController(state *State, tableService *tables.Service, tableReadControllers *TableReadController, settingProvider SettingsProvider) *TableWriteController { +func NewTableWriteController( + state *State, + tableService *tables.Service, + jobController *JobsController, + tableReadControllers *TableReadController, + settingProvider SettingsProvider, +) *TableWriteController { return &TableWriteController{ state: state, tableService: tableService, + jobController: jobController, tableReadControllers: tableReadControllers, settingProvider: settingProvider, } @@ -231,31 +239,6 @@ func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg { return ResultSetUpdated{} } -func (twc *TableWriteController) PutItem(idx int) tea.Msg { - if err := twc.assertReadWrite(); err != nil { - return events.Error(err) - } - - resultSet := twc.state.ResultSet() - if !resultSet.IsDirty(idx) { - return events.Error(errors.New("item is not dirty")) - } - - return events.PromptForInputMsg{ - Prompt: "put item? ", - OnDone: func(value string) tea.Msg { - if value != "y" { - return nil - } - - if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil { - return events.Error(err) - } - return ResultSetUpdated{} - }, - } -} - func (twc *TableWriteController) PutItems() tea.Msg { if err := twc.assertReadWrite(); err != nil { return events.Error(err) @@ -305,19 +288,18 @@ func (twc *TableWriteController) PutItems() tea.Msg { return events.StatusMsg("operation aborted") } - if err := twc.state.withResultSetReturningError(func(rs *models.ResultSet) error { - err := twc.tableService.PutSelectedItems(context.Background(), rs, itemsToPut) + return NewJob(twc.jobController, "Updating items…", func(ctx context.Context) (*models.ResultSet, error) { + rs := twc.state.ResultSet() + err := twc.tableService.PutSelectedItems(ctx, rs, itemsToPut) if err != nil { - return err + return nil, err } - return nil - }); err != nil { - return events.Error(err) - } - - return ResultSetUpdated{ - statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"), - } + return rs, nil + }).OnDone(func(rs *models.ResultSet) tea.Msg { + return ResultSetUpdated{ + statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"), + } + }).Submit() }, } } @@ -375,7 +357,7 @@ func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Msg { return events.Error(err) } - return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch) + return twc.tableReadControllers.doScan(resultSet, resultSet.Query, false, resultSetUpdateTouch) }, } } @@ -399,14 +381,14 @@ func (twc *TableWriteController) DeleteMarked() tea.Msg { return events.StatusMsg("operation aborted") } - ctx := context.Background() - if err := twc.tableService.Delete(ctx, resultSet.TableInfo, sliceutils.Map(markedItems, func(index models.ItemIndex) models.Item { - return index.Item - })); err != nil { - return events.Error(err) - } - - return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch) + return NewJob(twc.jobController, "Deleting items…", func(ctx context.Context) (struct{}, error) { + err := twc.tableService.Delete(ctx, resultSet.TableInfo, sliceutils.Map(markedItems, func(index models.ItemIndex) models.Item { + return index.Item + })) + return struct{}{}, err + }).OnDone(func(_ struct{}) tea.Msg { + return twc.tableReadControllers.doScan(resultSet, resultSet.Query, false, resultSetUpdateTouch) + }).Submit() }, } } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 96560f4..f755c1c 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -9,6 +9,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/providers/settingstore" "github.com/lmika/audax/internal/dynamo-browse/providers/workspacestore" "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" + "github.com/lmika/audax/internal/dynamo-browse/services/jobs" "github.com/lmika/audax/internal/dynamo-browse/services/tables" workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" "github.com/lmika/audax/test/testdynamo" @@ -48,7 +49,7 @@ func TestTableWriteController_NewItem(t *testing.T) { // Prompt for keys invokeCommandExpectingError(t, srv.writeController.NewItem()) - // Confirm no changes + // ConfirmYes no changes invokeCommand(t, srv.readController.Rescan()) newResultSet := srv.state.ResultSet() @@ -240,7 +241,7 @@ func TestTableWriteController_PutItem(t *testing.T) { // Modify the item and put it invokeCommandWithPrompt(t, srv.writeController.SetAttributeValue(0, models.StringItemType, "alpha"), "a new value") - invokeCommandWithPrompt(t, srv.writeController.PutItem(0), "y") + invokeCommandWithPrompt(t, srv.writeController.PutItems(), "y") // Rescan the table invokeCommand(t, srv.readController.Rescan()) @@ -260,7 +261,7 @@ func TestTableWriteController_PutItem(t *testing.T) { // Modify the item but do not put it invokeCommandWithPrompt(t, srv.writeController.SetAttributeValue(0, models.StringItemType, "alpha"), "a new value") - invokeCommandWithPrompt(t, srv.writeController.PutItem(0), "n") + invokeCommandWithPrompt(t, srv.writeController.PutItems(), "n") current, _ := srv.state.ResultSet().Items()[0].AttributeValueAsString("alpha") assert.Equal(t, "a new value", current) @@ -282,7 +283,7 @@ func TestTableWriteController_PutItem(t *testing.T) { assert.Equal(t, "This is some value", before) assert.False(t, srv.state.ResultSet().IsDirty(0)) - invokeCommandExpectingError(t, srv.writeController.PutItem(0)) + invokeCommand(t, srv.writeController.PutItems()) }) t.Run("should not put the selected item if in read-only mode", func(t *testing.T) { @@ -296,7 +297,7 @@ func TestTableWriteController_PutItem(t *testing.T) { // Modify the item but do not put it invokeCommandWithPrompt(t, srv.writeController.SetAttributeValue(0, models.StringItemType, "alpha"), "a new value") - invokeCommandExpectingError(t, srv.writeController.PutItem(0)) + invokeCommandExpectingError(t, srv.writeController.PutItems()) current, _ := srv.state.ResultSet().Items()[0].AttributeValueAsString("alpha") assert.Equal(t, "a new value", current) @@ -596,8 +597,9 @@ func newService(t *testing.T, cfg serviceConfig) *services { eventBus := bus.New() state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, eventBus, cfg.tableName) - writeController := controllers.NewTableWriteController(state, service, readController, settingStore) + jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true) + readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, jobsController, eventBus, cfg.tableName) + writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) settingsController := controllers.NewSettingsController(settingStore) columnsController := controllers.NewColumnsController(eventBus) exportController := controllers.NewExportController(state, columnsController) diff --git a/internal/dynamo-browse/models/errors.go b/internal/dynamo-browse/models/errors.go index 9b5275e..5b1a909 100644 --- a/internal/dynamo-browse/models/errors.go +++ b/internal/dynamo-browse/models/errors.go @@ -1,5 +1,23 @@ package models -import "github.com/pkg/errors" +import ( + "github.com/pkg/errors" +) var ErrReadOnly = errors.New("in read-only mode") + +type PartialResultsError struct { + err error +} + +func NewPartialResultsError(err error) PartialResultsError { + return PartialResultsError{err: err} +} + +func (pr PartialResultsError) Error() string { + return "partial results received" +} + +func (pr PartialResultsError) Unwrap() error { + return pr.err +} diff --git a/internal/dynamo-browse/providers/dynamo/provider.go b/internal/dynamo-browse/providers/dynamo/provider.go index 9ecc82f..2488d1f 100644 --- a/internal/dynamo-browse/providers/dynamo/provider.go +++ b/internal/dynamo-browse/providers/dynamo/provider.go @@ -2,13 +2,16 @@ package dynamo import ( "context" + "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/audax/internal/common/sliceutils" "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/services/jobs" "github.com/pkg/errors" + "time" ) type Provider struct { @@ -70,6 +73,8 @@ func (p *Provider) PutItems(ctx context.Context, name string, items []models.Ite } func (p *Provider) batchPutItems(ctx context.Context, name string, items []models.Item) error { + nextUpdate := time.Now().Add(1 * time.Second) + reqs := len(items)/25 + 1 for rn := 0; rn < reqs; rn++ { s, f := rn*25, (rn+1)*25 @@ -90,6 +95,11 @@ func (p *Provider) batchPutItems(ctx context.Context, name string, items []model if err != nil { errors.Wrapf(err, "unable to put page %v of back puts", rn) } + + if time.Now().After(nextUpdate) { + jobs.PostUpdate(ctx, fmt.Sprintf("updated %d items", f)) + nextUpdate = time.Now().Add(1 * time.Second) + } } return nil } @@ -109,10 +119,15 @@ func (p *Provider) ScanItems(ctx context.Context, tableName string, filterExpr * items := make([]models.Item, 0) + nextUpdate := time.Now().Add(1 * time.Second) + outer: for paginator.HasMorePages() { res, err := paginator.NextPage(ctx) if err != nil { + if ctx.Err() != nil { + return items, models.NewPartialResultsError(ctx.Err()) + } return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName) } @@ -121,6 +136,11 @@ outer: if len(items) >= maxItems { break outer } + + if time.Now().After(nextUpdate) { + jobs.PostUpdate(ctx, fmt.Sprintf("found %d items", len(items))) + nextUpdate = time.Now().Add(1 * time.Second) + } } } @@ -143,10 +163,15 @@ func (p *Provider) QueryItems(ctx context.Context, tableName string, filterExpr items := make([]models.Item, 0) + nextUpdate := time.Now().Add(1 * time.Second) + outer: for paginator.HasMorePages() { res, err := paginator.NextPage(ctx) if err != nil { + if ctx.Err() != nil { + return items, models.NewPartialResultsError(ctx.Err()) + } return nil, errors.Wrapf(err, "cannot execute query on table %v", tableName) } @@ -155,6 +180,11 @@ outer: if len(items) >= maxItems { break outer } + + if time.Now().After(nextUpdate) { + jobs.PostUpdate(ctx, fmt.Sprintf("found %d items", len(items))) + nextUpdate = time.Now().Add(1 * time.Second) + } } } diff --git a/internal/dynamo-browse/services/jobs/ctx.go b/internal/dynamo-browse/services/jobs/ctx.go new file mode 100644 index 0000000..b33a489 --- /dev/null +++ b/internal/dynamo-browse/services/jobs/ctx.go @@ -0,0 +1,25 @@ +package jobs + +import ( + "context" +) + +type jobUpdaterKeyType struct{} + +var jobUpdaterKey = jobUpdaterKeyType{} + +type jobUpdaterValue struct { + msgUpdate chan string +} + +func PostUpdate(ctx context.Context, msg string) { + val, hasVal := ctx.Value(jobUpdaterKey).(*jobUpdaterValue) + if !hasVal { + return + } + + select { + case val.msgUpdate <- msg: + default: + } +} diff --git a/internal/dynamo-browse/services/jobs/events.go b/internal/dynamo-browse/services/jobs/events.go new file mode 100644 index 0000000..0b1a1d4 --- /dev/null +++ b/internal/dynamo-browse/services/jobs/events.go @@ -0,0 +1,9 @@ +package jobs + +const ( + JobEventForegroundDone = "job_foreground_done" +) + +type JobDoneEvent struct { + Err error +} diff --git a/internal/dynamo-browse/services/jobs/jobs.go b/internal/dynamo-browse/services/jobs/jobs.go new file mode 100644 index 0000000..4e7291c --- /dev/null +++ b/internal/dynamo-browse/services/jobs/jobs.go @@ -0,0 +1,79 @@ +package jobs + +import ( + "context" + bus "github.com/lmika/events" + "sync" +) + +type Job func(ctx context.Context) + +type jobInfo struct { + ctx context.Context + cancelFn func() +} + +type Services struct { + bus *bus.Bus + + mutex *sync.Mutex + foregroundJob *jobInfo +} + +func NewService(bus *bus.Bus) *Services { + return &Services{ + bus: bus, + mutex: new(sync.Mutex), + } +} + +// SubmitForegroundJob starts a foreground job. +func (jc *Services) SubmitForegroundJob(job Job, onJobUpdate func(msg string)) { + // TODO: if there's already a foreground job, then return error + + ctx, cancelFn := context.WithCancel(context.Background()) + + jobUpdateChan := make(chan string) + jobUpdater := &jobUpdaterValue{msgUpdate: jobUpdateChan} + ctx = context.WithValue(ctx, jobUpdaterKey, jobUpdater) + + newJobInfo := &jobInfo{ + ctx: ctx, + cancelFn: cancelFn, + } + // TODO: needs to be protected by the mutex + jc.foregroundJob = newJobInfo + + go func() { + defer cancelFn() + defer close(jobUpdateChan) + + job(newJobInfo.ctx) + + // TODO: needs to be protected by the mutex + jc.foregroundJob = nil + }() + + go func() { + for update := range jobUpdateChan { + onJobUpdate(update) + } + }() +} + +func (jc *Services) CancelForegroundJob() bool { + // TODO: needs to be protected by the mutex + if jc.foregroundJob != nil { + // A nil cancel for a non-nil foreground job indicates that the cancellation function + // has been called and the job is in the process of stopping + if jc.foregroundJob.cancelFn == nil { + return false + } + + jc.foregroundJob.cancelFn() + jc.foregroundJob.cancelFn = nil + return true + } + + return false +} diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index 257dbc5..a87d660 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -2,10 +2,13 @@ package tables import ( "context" + "fmt" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/lmika/audax/internal/common/sliceutils" + "github.com/lmika/audax/internal/dynamo-browse/services/jobs" "log" "strings" + "time" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/pkg/errors" @@ -60,7 +63,7 @@ func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, expr results, err = s.provider.ScanItems(ctx, tableInfo.Name, filterExpr, limit) } - if err != nil { + if err != nil && len(results) == 0 { return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name) } @@ -73,7 +76,7 @@ func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, expr resultSet.SetItems(results) resultSet.RefreshColumns() - return resultSet, nil + return resultSet, err } func (s *Service) Put(ctx context.Context, tableInfo *models.TableInfo, item models.Item) error { @@ -126,10 +129,17 @@ func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items return err } - for _, item := range items { + nextUpdate := time.Now().Add(1 * time.Second) + + for i, item := range items { if err := s.provider.DeleteItem(ctx, tableInfo.Name, item.KeyValue(tableInfo)); err != nil { return errors.Wrapf(err, "cannot delete item") } + + if time.Now().After(nextUpdate) { + jobs.PostUpdate(ctx, fmt.Sprintf("delete %d items", i)) + nextUpdate = time.Now().Add(1 * time.Second) + } } return nil } @@ -150,6 +160,10 @@ func (s *Service) assertReadWrite() error { // TODO: move into a new service func (s *Service) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet { + if resultSet == nil { + return nil + } + for i, item := range resultSet.Items() { if filter == "" { resultSet.SetHidden(i, false) diff --git a/internal/dynamo-browse/ui/keybindings/defaults.go b/internal/dynamo-browse/ui/keybindings/defaults.go index 7a996f1..912bee2 100644 --- a/internal/dynamo-browse/ui/keybindings/defaults.go +++ b/internal/dynamo-browse/ui/keybindings/defaults.go @@ -35,7 +35,8 @@ func Default() *KeyBindings { CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")), PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")), ShowColumnOverlay: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "show column overlay")), - Quit: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("ctrl+c/esc", "quit")), + CancelRunningJob: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "cancel running job or quit")), + Quit: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), }, } } diff --git a/internal/dynamo-browse/ui/keybindings/keybindings.go b/internal/dynamo-browse/ui/keybindings/keybindings.go index 8d13218..b4e6a3c 100644 --- a/internal/dynamo-browse/ui/keybindings/keybindings.go +++ b/internal/dynamo-browse/ui/keybindings/keybindings.go @@ -42,5 +42,6 @@ type ViewKeyBindings struct { CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"` PromptForCommand key.Binding `keymap:"prompt-for-command"` ShowColumnOverlay key.Binding `keymap:"show-column-overlay"` + CancelRunningJob key.Binding `keymap:"cancel-running-job"` Quit key.Binding `keymap:"quit"` } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index f00efe5..bee7ce1 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -43,6 +43,7 @@ type Model struct { settingsController *controllers.SettingsController exportController *controllers.ExportController commandController *commandctrl.CommandController + jobController *controllers.JobsController colSelector *colselector.Model itemEdit *dynamoitemedit.Model statusAndPrompt *statusandprompt.StatusAndPrompt @@ -63,6 +64,7 @@ func NewModel( columnsController *controllers.ColumnsController, exportController *controllers.ExportController, settingsController *controllers.SettingsController, + jobController *controllers.JobsController, itemRendererService *itemrenderer.Service, cc *commandctrl.CommandController, keyBindingController *controllers.KeyBindingController, @@ -96,7 +98,23 @@ func NewModel( } return exportController.ExportCSV(args[0]) }, - "unmark": commandctrl.NoArgCommand(rc.Unmark), + "mark": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + var markOp = controllers.MarkOpMark + if len(args) > 0 { + switch args[0] { + case "all": + markOp = controllers.MarkOpMark + case "none": + markOp = controllers.MarkOpUnmark + case "toggle": + markOp = controllers.MarkOpToggle + default: + return events.Error(errors.New("unrecognised mark operation")) + } + } + + return rc.Mark(markOp) + }, "delete": commandctrl.NoArgCommand(wc.DeleteMarked), // TEMP @@ -166,10 +184,11 @@ func NewModel( }, // Aliases - "sa": cc.Alias("set-attr"), - "da": cc.Alias("del-attr"), - "w": cc.Alias("put"), - "q": cc.Alias("quit"), + "unmark": cc.Alias("mark", []string{"none"}), + "sa": cc.Alias("set-attr", nil), + "da": cc.Alias("del-attr", nil), + "w": cc.Alias("put", nil), + "q": cc.Alias("quit", nil), }, }) @@ -179,6 +198,7 @@ func NewModel( tableReadController: rc, tableWriteController: wc, commandController: cc, + jobController: jobController, itemEdit: itemEdit, colSelector: colSelector, statusAndPrompt: statusAndPrompt, @@ -236,6 +256,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.commandController.Prompt case key.Matches(msg, m.keyMap.PromptForTable): return m, events.SetTeaMessage(m.tableReadController.ListTables()) + case key.Matches(msg, m.keyMap.CancelRunningJob): + return m, events.SetTeaMessage(m.jobController.CancelRunningJob(func() tea.Msg { + return tea.Quit() + })) case key.Matches(msg, m.keyMap.Quit): return m, tea.Quit } @@ -254,7 +278,10 @@ func (m Model) Init() tea.Cmd { log.Println(err) } - return m.tableReadController.Init + return tea.Batch( + m.tableReadController.Init, + m.root.Init(), + ) } func (m Model) View() string { diff --git a/internal/dynamo-browse/ui/teamodels/colselector/model.go b/internal/dynamo-browse/ui/teamodels/colselector/model.go index f65cfd4..087546c 100644 --- a/internal/dynamo-browse/ui/teamodels/colselector/model.go +++ b/internal/dynamo-browse/ui/teamodels/colselector/model.go @@ -48,11 +48,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.compositor.ClearOverlay() case controllers.ColumnsUpdated: m.colListModel.refreshTable() - m.subModel = cc.Collect(m.subModel.Update(msg)) + m.subModel = cc.Collect(m.subModel.Update(msg)).(tea.Model) case tea.KeyMsg: m.compositor = cc.Collect(m.compositor.Update(msg)).(*layout.Compositor) default: - m.subModel = cc.Collect(m.subModel.Update(msg)) + m.subModel = cc.Collect(m.subModel.Update(msg)).(tea.Model) } return m, cc.Cmd() } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/colmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/colmodel.go index b4c8882..53f567f 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/colmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/colmodel.go @@ -5,6 +5,10 @@ type columnModel struct { } func (cm columnModel) Len() int { + if len(cm.m.columns) == 0 { + return 0 + } + return len(cm.m.columns[cm.m.colOffset:]) + 1 } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 9a0c9d7..02f0c70 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -118,6 +118,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) setLeftmostDisplayedColumn(newCol int) { + if m.columnsProvider == nil || m.columnsProvider.Columns() == nil { + return + } + if newCol < 0 { m.colOffset = 0 } else if newCol >= len(m.columnsProvider.Columns().Columns) { @@ -163,11 +167,8 @@ func (m *Model) rebuildTable(targetTbl *table.Model) { // Use the target table model if you can, but if it's nil or the number of rows is smaller than the // existing table, create a new one - if targetTbl == nil || len(resultSet.Items()) > len(m.rows) { + if targetTbl == nil { tbl = table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight()) - if targetTbl != nil { - tbl.GoBottom() - } } else { tbl = *targetTbl } @@ -191,6 +192,7 @@ func (m *Model) rebuildTable(targetTbl *table.Model) { m.rows = newRows tbl.SetRows(newRows) + tbl.GoTop() // Preserve top and cursor location m.table = tbl } @@ -205,6 +207,12 @@ func (m *Model) SelectedItemIndex() int { func (m *Model) selectedItem() (itemTableRow, bool) { resultSet := m.resultSet + + // Fix bug?? + if m.table.Cursor() < 0 { + return itemTableRow{}, false + } + if resultSet != nil && len(m.rows) > 0 { selectedItem, ok := m.table.SelectedRow().(itemTableRow) if ok { diff --git a/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go b/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go index fb6e2f3..550f8ee 100644 --- a/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go +++ b/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go @@ -23,7 +23,7 @@ func (m *Model) Init() tea.Cmd { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cc utils.CmdCollector - m.baseMode = cc.Collect(m.baseMode.Update(msg)) + m.baseMode = cc.Collect(m.baseMode.Update(msg)).(tea.Model) return m, cc.Cmd() } diff --git a/internal/dynamo-browse/ui/teamodels/layout/zstack.go b/internal/dynamo-browse/ui/teamodels/layout/zstack.go index 88860b6..63aa687 100644 --- a/internal/dynamo-browse/ui/teamodels/layout/zstack.go +++ b/internal/dynamo-browse/ui/teamodels/layout/zstack.go @@ -39,10 +39,10 @@ func (vb ZStack) Update(msg tea.Msg) (m tea.Model, cmd tea.Cmd) { // All other messages go to each model var cc utils.CmdCollector - vb.visibleModel = cc.Collect(vb.visibleModel.Update(msg)) - vb.focusedModel = cc.Collect(vb.focusedModel.Update(msg)) + vb.visibleModel = cc.Collect(vb.visibleModel.Update(msg)).(tea.Model) + vb.focusedModel = cc.Collect(vb.focusedModel.Update(msg)).(tea.Model) for i, c := range vb.otherModels { - vb.otherModels[i] = cc.Collect(c.Update(msg)) + vb.otherModels[i] = cc.Collect(c.Update(msg)).(tea.Model) } return vb, cc.Cmd() } diff --git a/internal/dynamo-browse/ui/teamodels/modal/model.go b/internal/dynamo-browse/ui/teamodels/modal/model.go index 40a95fb..a9e9767 100644 --- a/internal/dynamo-browse/ui/teamodels/modal/model.go +++ b/internal/dynamo-browse/ui/teamodels/modal/model.go @@ -50,16 +50,16 @@ func (m Modal) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg, tea.MouseMsg: // only notify top level stack if len(m.modeStack) > 0 { - m.modeStack[len(m.modeStack)-1] = cc.Collect(m.modeStack[len(m.modeStack)-1].Update(msg)) + m.modeStack[len(m.modeStack)-1] = cc.Collect(m.modeStack[len(m.modeStack)-1].Update(msg)).(tea.Model) } else { - m.baseMode = cc.Collect(m.baseMode.Update(msg)) + m.baseMode = cc.Collect(m.baseMode.Update(msg)).(tea.Model) } default: // notify all modes of other events // TODO: is this right? - m.baseMode = cc.Collect(m.baseMode.Update(msg)) + m.baseMode = cc.Collect(m.baseMode.Update(msg)).(tea.Model) for i, s := range m.modeStack { - m.modeStack[i] = cc.Collect(s.Update(msg)) + m.modeStack[i] = cc.Collect(s.Update(msg)).(tea.Model) } } diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index 3c1b3dd..2831210 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -1,6 +1,7 @@ package statusandprompt import ( + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -14,13 +15,15 @@ import ( // StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt // event is received, focus will be torn away and the user will be given a prompt the enter text. type StatusAndPrompt struct { - model layout.ResizingModel - style Style - modeLine string - statusMessage string - pendingInput *events.PromptForInputMsg - textInput textinput.Model - width int + model layout.ResizingModel + style Style + modeLine string + statusMessage string + spinner spinner.Model + spinnerVisible bool + pendingInput *events.PromptForInputMsg + textInput textinput.Model + width int } type Style struct { @@ -29,11 +32,20 @@ type Style struct { func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt { textInput := textinput.New() - return &StatusAndPrompt{model: model, style: style, statusMessage: initialMsg, modeLine: "", textInput: textInput} + return &StatusAndPrompt{ + model: model, + style: style, + statusMessage: initialMsg, + modeLine: "", + spinner: spinner.New(spinner.WithSpinner(spinner.Line)), + textInput: textInput, + } } func (s *StatusAndPrompt) Init() tea.Cmd { - return s.model.Init() + return tea.Batch( + s.model.Init(), + ) } func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -47,6 +59,14 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case events.WrappedStatusMsg: s.statusMessage = string(msg.Message) cc.Add(func() tea.Msg { return msg.Next }) + case events.ForegroundJobUpdate: + if msg.JobRunning { + s.spinnerVisible = true + s.statusMessage = msg.JobStatus + cc.Add(s.spinner.Tick) + } else { + s.spinnerVisible = false + } case events.ModeMessage: s.modeLine = string(msg) case events.MessageWithStatus: @@ -92,8 +112,10 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - newModel := cc.Collect(s.model.Update(msg)) - s.model = newModel.(layout.ResizingModel) + if s.spinnerVisible { + s.spinner = cc.Collect(s.spinner.Update(msg)).(spinner.Model) + } + s.model = cc.Collect(s.model.Update(msg)).(layout.ResizingModel) return s, cc.Cmd() } @@ -122,5 +144,9 @@ func (s *StatusAndPrompt) viewStatus() string { statusLine = s.statusMessage } + if s.spinnerVisible { + statusLine = lipgloss.JoinHorizontal(lipgloss.Left, s.spinner.View(), " ", statusLine) + } + return lipgloss.JoinVertical(lipgloss.Top, modeLine, statusLine) } diff --git a/internal/dynamo-browse/ui/teamodels/tableselect/model.go b/internal/dynamo-browse/ui/teamodels/tableselect/model.go index d037b84..afdae01 100644 --- a/internal/dynamo-browse/ui/teamodels/tableselect/model.go +++ b/internal/dynamo-browse/ui/teamodels/tableselect/model.go @@ -4,6 +4,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/lmika/audax/internal/common/ui/events" "github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout" @@ -55,7 +56,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var sel controllers.PromptForTableMsg sel, m.pendingSelection = *m.pendingSelection, nil - return m, func() tea.Msg { return sel.OnSelected(m.listController.list.SelectedItem().(tableItem).name) } + if selTableItem, isTableItem := m.listController.list.SelectedItem().(tableItem); isTableItem { + return m, events.SetTeaMessage(sel.OnSelected(selTableItem.name)) + } + return m, events.SetTeaMessage(sel.OnSelected("")) } } @@ -67,7 +71,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.pendingSelection != nil { m.listController = cc.Collect(m.listController.Update(msg)).(listController) } - m.submodel = cc.Collect(m.submodel.Update(msg)) + m.submodel = cc.Collect(m.submodel.Update(msg)).(tea.Model) return m, cc.Cmd() } diff --git a/internal/dynamo-browse/ui/teamodels/utils/utils.go b/internal/dynamo-browse/ui/teamodels/utils/utils.go index 8f8a0f6..d5b1332 100644 --- a/internal/dynamo-browse/ui/teamodels/utils/utils.go +++ b/internal/dynamo-browse/ui/teamodels/utils/utils.go @@ -12,7 +12,7 @@ func (c *CmdCollector) Add(cmd tea.Cmd) { } } -func (c *CmdCollector) Collect(m tea.Model, cmd tea.Cmd) tea.Model { +func (c *CmdCollector) Collect(m any, cmd tea.Cmd) any { if cmd != nil { c.cmds = append(c.cmds, cmd) } diff --git a/internal/ssm-browse/controllers/ssmcontroller.go b/internal/ssm-browse/controllers/ssmcontroller.go index c54f316..1ecfeab 100644 --- a/internal/ssm-browse/controllers/ssmcontroller.go +++ b/internal/ssm-browse/controllers/ssmcontroller.go @@ -77,7 +77,7 @@ func (c *SSMController) Clone(param models.SSMParameter) tea.Msg { } func (c *SSMController) DeleteParameter(param models.SSMParameter) tea.Msg { - return events.Confirm("delete parameter? ", func() tea.Msg { + return events.ConfirmYes("delete parameter? ", func() tea.Msg { ctx := context.Background() if err := c.service.Delete(ctx, param); err != nil { return events.Error(err)