Issue 23: Added progress indicators and cancellation (#34)

- Wrapped all table operations in a new foreground job context, which mediates foreground tasks.
- Added cancellation support and partial results for table read operations.
- Added the "mark" command, which can mark, unmark & toggle marked items
- Added support for alias arguments.
- Removed the "unmark" command, and replaced it as an alias to the "marked" command
- Fixed seg faults raised when there is no table shown in the result set.
This commit is contained in:
Leon Mika 2022-10-10 10:15:25 +11:00 committed by GitHub
parent 982d3a9ca7
commit 79692302af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 609 additions and 170 deletions

View file

@ -16,6 +16,7 @@ import (
"github.com/lmika/audax/internal/dynamo-browse/providers/settingstore" "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/providers/workspacestore"
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "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" keybindings_service "github.com/lmika/audax/internal/dynamo-browse/services/keybindings"
"github.com/lmika/audax/internal/dynamo-browse/services/tables" "github.com/lmika/audax/internal/dynamo-browse/services/tables"
workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces"
@ -94,10 +95,12 @@ func main() {
tableService := tables.NewService(dynamoProvider, settingStore) tableService := tables.NewService(dynamoProvider, settingStore)
workspaceService := workspaces_service.NewService(resultSetSnapshotStore) workspaceService := workspaces_service.NewService(resultSetSnapshotStore)
itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo) itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo)
jobsService := jobs.NewService(eventBus)
state := controllers.NewState() state := controllers.NewState()
tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, eventBus, *flagTable) jobsController := controllers.NewJobsController(jobsService, eventBus, false)
tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController, settingStore) tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, jobsController, eventBus, *flagTable)
tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore)
columnsController := controllers.NewColumnsController(eventBus) columnsController := controllers.NewColumnsController(eventBus)
exportController := controllers.NewExportController(state, columnsController) exportController := controllers.NewExportController(state, columnsController)
settingsController := controllers.NewSettingsController(settingStore) settingsController := controllers.NewSettingsController(settingStore)
@ -114,6 +117,7 @@ func main() {
columnsController, columnsController,
exportController, exportController,
settingsController, settingsController,
jobsController,
itemRendererService, itemRendererService,
commandController, commandController,
keyBindingController, keyBindingController,
@ -125,6 +129,8 @@ func main() {
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
jobsController.SetMessageSender(p.Send)
log.Println("launching") log.Println("launching")
if err := p.Start(); err != nil { if err := p.Start(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err) fmt.Printf("Alas, there's been an error: %v", err)

View file

@ -57,14 +57,20 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms
return command(ctx, tokens[1:]) 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 { return func(ctx ExecContext, args []string) tea.Msg {
command := c.lookupCommand(commandName) command := c.lookupCommand(commandName)
if command == nil { if command == nil {
return events.Error(errors.New("no such command: " + commandName)) 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)
} }
} }

View file

@ -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 { return PromptForInput(prompt, func(value string) tea.Msg {
if value == "y" { if value == "y" {
return onYes() return onYes()

View file

@ -0,0 +1,6 @@
package events
type ForegroundJobUpdate struct {
JobRunning bool
JobStatus string
}

View file

@ -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,
}
}

View file

@ -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()
}

View file

@ -27,7 +27,7 @@ func (kb *KeyBindingController) Rebind(bindingName string, newKey string, force
var keyAlreadyBoundErr keybindings.KeyAlreadyBoundError var keyAlreadyBoundErr keybindings.KeyAlreadyBoundError
if errors.As(err, &keyAlreadyBoundErr) { if errors.As(err, &keyAlreadyBoundErr) {
promptMsg := fmt.Sprintf("Key '%v' already bound to '%v'. Continue? ", keyAlreadyBoundErr.Key, keyAlreadyBoundErr.ExistingBindingName) 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) err := kb.service.Rebind(bindingName, newKey, true)
if err != nil { if err != nil {
return events.Error(err) return events.Error(err)

View file

@ -29,10 +29,19 @@ const (
resultSetUpdateTouch resultSetUpdateTouch
) )
type MarkOp int
const (
MarkOpMark MarkOp = iota
MarkOpUnmark
MarkOpToggle
)
type TableReadController struct { type TableReadController struct {
tableService TableReadService tableService TableReadService
workspaceService *workspaces.ViewSnapshotService workspaceService *workspaces.ViewSnapshotService
itemRendererService *itemrenderer.Service itemRendererService *itemrenderer.Service
jobController *JobsController
eventBus *bus.Bus eventBus *bus.Bus
tableName string tableName string
loadFromLastView bool loadFromLastView bool
@ -48,6 +57,7 @@ func NewTableReadController(
tableService TableReadService, tableService TableReadService,
workspaceService *workspaces.ViewSnapshotService, workspaceService *workspaces.ViewSnapshotService,
itemRendererService *itemrenderer.Service, itemRendererService *itemrenderer.Service,
jobController *JobsController,
eventBus *bus.Bus, eventBus *bus.Bus,
tableName string, tableName string,
) *TableReadController { ) *TableReadController {
@ -56,6 +66,7 @@ func NewTableReadController(
tableService: tableService, tableService: tableService,
workspaceService: workspaceService, workspaceService: workspaceService,
itemRendererService: itemRendererService, itemRendererService: itemRendererService,
jobController: jobController,
eventBus: eventBus, eventBus: eventBus,
tableName: tableName, tableName: tableName,
mutex: new(sync.Mutex), mutex: new(sync.Mutex),
@ -79,57 +90,67 @@ func (c *TableReadController) Init() tea.Msg {
} }
func (c *TableReadController) ListTables() tea.Msg { func (c *TableReadController) ListTables() tea.Msg {
return NewJob(c.jobController, "Listing tables…", func(ctx context.Context) (any, error) {
tables, err := c.tableService.ListTables(context.Background()) tables, err := c.tableService.ListTables(context.Background())
if err != nil { if err != nil {
return events.Error(err) 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 { 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) tableInfo, err := c.tableService.Describe(ctx, name)
if err != nil { if err != nil {
return events.Error(errors.Wrapf(err, "cannot describe %v", c.tableName)) return nil, errors.Wrapf(err, "cannot describe %v", c.tableName)
} }
resultSet, err := c.tableService.Scan(ctx, tableInfo) resultSet, err := c.tableService.Scan(ctx, tableInfo)
if err != nil { if resultSet != nil {
return events.Error(err)
}
resultSet = c.tableService.Filter(resultSet, c.state.Filter()) 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 { func (c *TableReadController) PromptForQuery() tea.Msg {
return events.PromptForInputMsg{ return events.PromptForInputMsg{
Prompt: "query: ", Prompt: "query: ",
OnDone: func(value string) tea.Msg { 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 { func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFilter string, pushSnapshot bool) tea.Msg {
if query == "" { if query == "" {
return NewJob(c.jobController, "Scanning…", func(ctx context.Context) (*models.ResultSet, error) {
newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, nil) newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, nil)
if err != nil {
return events.Error(err)
}
if newFilter != "" { if newResultSet != nil && newFilter != "" {
newResultSet = c.tableService.Filter(newResultSet, 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) expr, err := queryexpr.Parse(query)
@ -138,15 +159,14 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi
} }
return c.doIfNoneDirty(func() tea.Msg { return c.doIfNoneDirty(func() tea.Msg {
return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) {
newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, expr) newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, expr)
if err != nil {
return events.Error(err)
}
if newFilter != "" { if newFilter != "" && newResultSet != nil {
newResultSet = c.tableService.Filter(newResultSet, newFilter) newResultSet = c.tableService.Filter(newResultSet, newFilter)
} }
return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery) 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 { func (c *TableReadController) Rescan() tea.Msg {
return c.doIfNoneDirty(func() tea.Msg { return c.doIfNoneDirty(func() tea.Msg {
resultSet := c.state.ResultSet() 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 { 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) newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query)
if err != nil { if newResultSet != nil {
return events.Error(err) newResultSet = c.tableService.Filter(newResultSet, c.state.Filter())
} }
newResultSet = c.tableService.Filter(newResultSet, c.state.Filter()) return newResultSet, err
}).OnEither(c.handleResultSetFromJobResult(c.state.Filter(), pushBackstack, op)).Submit()
return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack, op)
} }
func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool, op resultSetUpdateOp) tea.Msg { 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("") 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) { c.state.withResultSet(func(resultSet *models.ResultSet) {
for i := range resultSet.Items() { for i := range resultSet.Items() {
if resultSet.Hidden(i) {
continue
}
switch op {
case MarkOpMark:
resultSet.SetMark(i, true)
case MarkOpUnmark:
resultSet.SetMark(i, false) resultSet.SetMark(i, false)
case MarkOpToggle:
resultSet.SetMark(i, !resultSet.Marked(i))
}
} }
}) })
return ResultSetUpdated{} return ResultSetUpdated{}
@ -218,13 +249,37 @@ func (c *TableReadController) Filter() tea.Msg {
Prompt: "filter: ", Prompt: "filter: ",
OnDone: func(value string) tea.Msg { OnDone: func(value string) tea.Msg {
resultSet := c.state.ResultSet() 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 { func (c *TableReadController) ViewBack() tea.Msg {
viewSnapshot, err := c.workspaceService.ViewBack() viewSnapshot, err := c.workspaceService.ViewBack()
if err != nil { if err != nil {
@ -252,11 +307,15 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi
currentResultSet := c.state.ResultSet() currentResultSet := c.state.ResultSet()
if currentResultSet == nil { if currentResultSet == nil {
return NewJob(c.jobController, "Fetching table info…", func(ctx context.Context) (*models.TableInfo, error) {
tableInfo, err := c.tableService.Describe(context.Background(), viewSnapshot.TableName) tableInfo, err := c.tableService.Describe(context.Background(), viewSnapshot.TableName)
if err != nil { if err != nil {
return events.Error(err) return nil, err
} }
return tableInfo, nil
}).OnDone(func(tableInfo *models.TableInfo) tea.Msg {
return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false) return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false)
}).Submit()
} }
var currentQueryExpr string var currentQueryExpr string
@ -265,23 +324,24 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi
} }
if viewSnapshot.TableName == currentResultSet.TableInfo.Name && viewSnapshot.Query == currentQueryExpr { if viewSnapshot.TableName == currentResultSet.TableInfo.Name && viewSnapshot.Query == currentQueryExpr {
log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter) return NewJob(c.jobController, "Applying filter…", func(ctx context.Context) (*models.ResultSet, error) {
return c.tableService.Filter(currentResultSet, viewSnapshot.Filter), nil
newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter) }).OnEither(c.handleResultSetFromJobResult(viewSnapshot.Filter, false, resultSetUpdateSnapshotRestore)).Submit()
return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false, resultSetUpdateSnapshotRestore)
} }
return NewJob(c.jobController, "Running query…", func(ctx context.Context) (tea.Msg, error) {
tableInfo := currentResultSet.TableInfo tableInfo := currentResultSet.TableInfo
if viewSnapshot.TableName != currentResultSet.TableInfo.Name { if viewSnapshot.TableName != currentResultSet.TableInfo.Name {
tableInfo, err = c.tableService.Describe(context.Background(), viewSnapshot.TableName) tableInfo, err = c.tableService.Describe(context.Background(), viewSnapshot.TableName)
if err != nil { if err != nil {
return events.Error(err) return nil, err
} }
} }
log.Printf("backstack: running query: table = '%v', query = '%v', filter = '%v'", return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false), nil
tableInfo.Name, viewSnapshot.Query, viewSnapshot.Filter) }).OnDone(func(m tea.Msg) tea.Msg {
return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false) return m
}).Submit()
} }
func (c *TableReadController) CopyItemToClipboard(idx int) tea.Msg { func (c *TableReadController) CopyItemToClipboard(idx int) tea.Msg {

View file

@ -16,14 +16,22 @@ import (
type TableWriteController struct { type TableWriteController struct {
state *State state *State
tableService *tables.Service tableService *tables.Service
jobController *JobsController
tableReadControllers *TableReadController tableReadControllers *TableReadController
settingProvider SettingsProvider 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{ return &TableWriteController{
state: state, state: state,
tableService: tableService, tableService: tableService,
jobController: jobController,
tableReadControllers: tableReadControllers, tableReadControllers: tableReadControllers,
settingProvider: settingProvider, settingProvider: settingProvider,
} }
@ -231,31 +239,6 @@ func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg {
return ResultSetUpdated{} 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 { func (twc *TableWriteController) PutItems() tea.Msg {
if err := twc.assertReadWrite(); err != nil { if err := twc.assertReadWrite(); err != nil {
return events.Error(err) return events.Error(err)
@ -305,19 +288,18 @@ func (twc *TableWriteController) PutItems() tea.Msg {
return events.StatusMsg("operation aborted") return events.StatusMsg("operation aborted")
} }
if err := twc.state.withResultSetReturningError(func(rs *models.ResultSet) error { return NewJob(twc.jobController, "Updating items…", func(ctx context.Context) (*models.ResultSet, error) {
err := twc.tableService.PutSelectedItems(context.Background(), rs, itemsToPut) rs := twc.state.ResultSet()
err := twc.tableService.PutSelectedItems(ctx, rs, itemsToPut)
if err != nil { if err != nil {
return err return nil, err
} }
return nil return rs, nil
}); err != nil { }).OnDone(func(rs *models.ResultSet) tea.Msg {
return events.Error(err)
}
return ResultSetUpdated{ return ResultSetUpdated{
statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"), 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 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") return events.StatusMsg("operation aborted")
} }
ctx := context.Background() return NewJob(twc.jobController, "Deleting items…", func(ctx context.Context) (struct{}, error) {
if err := twc.tableService.Delete(ctx, resultSet.TableInfo, sliceutils.Map(markedItems, func(index models.ItemIndex) models.Item { err := twc.tableService.Delete(ctx, resultSet.TableInfo, sliceutils.Map(markedItems, func(index models.ItemIndex) models.Item {
return index.Item return index.Item
})); err != nil { }))
return events.Error(err) return struct{}{}, err
} }).OnDone(func(_ struct{}) tea.Msg {
return twc.tableReadControllers.doScan(resultSet, resultSet.Query, false, resultSetUpdateTouch)
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch) }).Submit()
}, },
} }
} }

View file

@ -9,6 +9,7 @@ import (
"github.com/lmika/audax/internal/dynamo-browse/providers/settingstore" "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/providers/workspacestore"
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "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" "github.com/lmika/audax/internal/dynamo-browse/services/tables"
workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces"
"github.com/lmika/audax/test/testdynamo" "github.com/lmika/audax/test/testdynamo"
@ -48,7 +49,7 @@ func TestTableWriteController_NewItem(t *testing.T) {
// Prompt for keys // Prompt for keys
invokeCommandExpectingError(t, srv.writeController.NewItem()) invokeCommandExpectingError(t, srv.writeController.NewItem())
// Confirm no changes // ConfirmYes no changes
invokeCommand(t, srv.readController.Rescan()) invokeCommand(t, srv.readController.Rescan())
newResultSet := srv.state.ResultSet() newResultSet := srv.state.ResultSet()
@ -240,7 +241,7 @@ func TestTableWriteController_PutItem(t *testing.T) {
// Modify the item and put it // Modify the item and put it
invokeCommandWithPrompt(t, srv.writeController.SetAttributeValue(0, models.StringItemType, "alpha"), "a new value") 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 // Rescan the table
invokeCommand(t, srv.readController.Rescan()) invokeCommand(t, srv.readController.Rescan())
@ -260,7 +261,7 @@ func TestTableWriteController_PutItem(t *testing.T) {
// Modify the item but do not put it // Modify the item but do not put it
invokeCommandWithPrompt(t, srv.writeController.SetAttributeValue(0, models.StringItemType, "alpha"), "a new value") 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") current, _ := srv.state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "a new value", current) 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.Equal(t, "This is some value", before)
assert.False(t, srv.state.ResultSet().IsDirty(0)) 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) { 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 // Modify the item but do not put it
invokeCommandWithPrompt(t, srv.writeController.SetAttributeValue(0, models.StringItemType, "alpha"), "a new value") 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") current, _ := srv.state.ResultSet().Items()[0].AttributeValueAsString("alpha")
assert.Equal(t, "a new value", current) assert.Equal(t, "a new value", current)
@ -596,8 +597,9 @@ func newService(t *testing.T, cfg serviceConfig) *services {
eventBus := bus.New() eventBus := bus.New()
state := controllers.NewState() state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, eventBus, cfg.tableName) jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true)
writeController := controllers.NewTableWriteController(state, service, readController, settingStore) readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, jobsController, eventBus, cfg.tableName)
writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore)
settingsController := controllers.NewSettingsController(settingStore) settingsController := controllers.NewSettingsController(settingStore)
columnsController := controllers.NewColumnsController(eventBus) columnsController := controllers.NewColumnsController(eventBus)
exportController := controllers.NewExportController(state, columnsController) exportController := controllers.NewExportController(state, columnsController)

View file

@ -1,5 +1,23 @@
package models package models
import "github.com/pkg/errors" import (
"github.com/pkg/errors"
)
var ErrReadOnly = errors.New("in read-only mode") 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
}

View file

@ -2,13 +2,16 @@ package dynamo
import ( import (
"context" "context"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws" "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/feature/dynamodb/expression"
"github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/audax/internal/common/sliceutils" "github.com/lmika/audax/internal/common/sliceutils"
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/lmika/audax/internal/dynamo-browse/services/jobs"
"github.com/pkg/errors" "github.com/pkg/errors"
"time"
) )
type Provider struct { 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 { 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 reqs := len(items)/25 + 1
for rn := 0; rn < reqs; rn++ { for rn := 0; rn < reqs; rn++ {
s, f := rn*25, (rn+1)*25 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 { if err != nil {
errors.Wrapf(err, "unable to put page %v of back puts", rn) 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 return nil
} }
@ -109,10 +119,15 @@ func (p *Provider) ScanItems(ctx context.Context, tableName string, filterExpr *
items := make([]models.Item, 0) items := make([]models.Item, 0)
nextUpdate := time.Now().Add(1 * time.Second)
outer: outer:
for paginator.HasMorePages() { for paginator.HasMorePages() {
res, err := paginator.NextPage(ctx) res, err := paginator.NextPage(ctx)
if err != nil { 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) return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName)
} }
@ -121,6 +136,11 @@ outer:
if len(items) >= maxItems { if len(items) >= maxItems {
break outer 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) items := make([]models.Item, 0)
nextUpdate := time.Now().Add(1 * time.Second)
outer: outer:
for paginator.HasMorePages() { for paginator.HasMorePages() {
res, err := paginator.NextPage(ctx) res, err := paginator.NextPage(ctx)
if err != nil { 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) return nil, errors.Wrapf(err, "cannot execute query on table %v", tableName)
} }
@ -155,6 +180,11 @@ outer:
if len(items) >= maxItems { if len(items) >= maxItems {
break outer break outer
} }
if time.Now().After(nextUpdate) {
jobs.PostUpdate(ctx, fmt.Sprintf("found %d items", len(items)))
nextUpdate = time.Now().Add(1 * time.Second)
}
} }
} }

View file

@ -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:
}
}

View file

@ -0,0 +1,9 @@
package jobs
const (
JobEventForegroundDone = "job_foreground_done"
)
type JobDoneEvent struct {
Err error
}

View file

@ -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
}

View file

@ -2,10 +2,13 @@ package tables
import ( import (
"context" "context"
"fmt"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/lmika/audax/internal/common/sliceutils" "github.com/lmika/audax/internal/common/sliceutils"
"github.com/lmika/audax/internal/dynamo-browse/services/jobs"
"log" "log"
"strings" "strings"
"time"
"github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models"
"github.com/pkg/errors" "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) 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) 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.SetItems(results)
resultSet.RefreshColumns() resultSet.RefreshColumns()
return resultSet, nil return resultSet, err
} }
func (s *Service) Put(ctx context.Context, tableInfo *models.TableInfo, item models.Item) error { 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 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 { if err := s.provider.DeleteItem(ctx, tableInfo.Name, item.KeyValue(tableInfo)); err != nil {
return errors.Wrapf(err, "cannot delete item") 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 return nil
} }
@ -150,6 +160,10 @@ func (s *Service) assertReadWrite() error {
// TODO: move into a new service // TODO: move into a new service
func (s *Service) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet { func (s *Service) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet {
if resultSet == nil {
return nil
}
for i, item := range resultSet.Items() { for i, item := range resultSet.Items() {
if filter == "" { if filter == "" {
resultSet.SetHidden(i, false) resultSet.SetHidden(i, false)

View file

@ -35,7 +35,8 @@ func Default() *KeyBindings {
CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")), CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")),
PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")), PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")),
ShowColumnOverlay: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "show column overlay")), 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")),
}, },
} }
} }

View file

@ -42,5 +42,6 @@ type ViewKeyBindings struct {
CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"` CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"`
PromptForCommand key.Binding `keymap:"prompt-for-command"` PromptForCommand key.Binding `keymap:"prompt-for-command"`
ShowColumnOverlay key.Binding `keymap:"show-column-overlay"` ShowColumnOverlay key.Binding `keymap:"show-column-overlay"`
CancelRunningJob key.Binding `keymap:"cancel-running-job"`
Quit key.Binding `keymap:"quit"` Quit key.Binding `keymap:"quit"`
} }

View file

@ -43,6 +43,7 @@ type Model struct {
settingsController *controllers.SettingsController settingsController *controllers.SettingsController
exportController *controllers.ExportController exportController *controllers.ExportController
commandController *commandctrl.CommandController commandController *commandctrl.CommandController
jobController *controllers.JobsController
colSelector *colselector.Model colSelector *colselector.Model
itemEdit *dynamoitemedit.Model itemEdit *dynamoitemedit.Model
statusAndPrompt *statusandprompt.StatusAndPrompt statusAndPrompt *statusandprompt.StatusAndPrompt
@ -63,6 +64,7 @@ func NewModel(
columnsController *controllers.ColumnsController, columnsController *controllers.ColumnsController,
exportController *controllers.ExportController, exportController *controllers.ExportController,
settingsController *controllers.SettingsController, settingsController *controllers.SettingsController,
jobController *controllers.JobsController,
itemRendererService *itemrenderer.Service, itemRendererService *itemrenderer.Service,
cc *commandctrl.CommandController, cc *commandctrl.CommandController,
keyBindingController *controllers.KeyBindingController, keyBindingController *controllers.KeyBindingController,
@ -96,7 +98,23 @@ func NewModel(
} }
return exportController.ExportCSV(args[0]) 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), "delete": commandctrl.NoArgCommand(wc.DeleteMarked),
// TEMP // TEMP
@ -166,10 +184,11 @@ func NewModel(
}, },
// Aliases // Aliases
"sa": cc.Alias("set-attr"), "unmark": cc.Alias("mark", []string{"none"}),
"da": cc.Alias("del-attr"), "sa": cc.Alias("set-attr", nil),
"w": cc.Alias("put"), "da": cc.Alias("del-attr", nil),
"q": cc.Alias("quit"), "w": cc.Alias("put", nil),
"q": cc.Alias("quit", nil),
}, },
}) })
@ -179,6 +198,7 @@ func NewModel(
tableReadController: rc, tableReadController: rc,
tableWriteController: wc, tableWriteController: wc,
commandController: cc, commandController: cc,
jobController: jobController,
itemEdit: itemEdit, itemEdit: itemEdit,
colSelector: colSelector, colSelector: colSelector,
statusAndPrompt: statusAndPrompt, statusAndPrompt: statusAndPrompt,
@ -236,6 +256,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.commandController.Prompt return m, m.commandController.Prompt
case key.Matches(msg, m.keyMap.PromptForTable): case key.Matches(msg, m.keyMap.PromptForTable):
return m, events.SetTeaMessage(m.tableReadController.ListTables()) 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): case key.Matches(msg, m.keyMap.Quit):
return m, tea.Quit return m, tea.Quit
} }
@ -254,7 +278,10 @@ func (m Model) Init() tea.Cmd {
log.Println(err) log.Println(err)
} }
return m.tableReadController.Init return tea.Batch(
m.tableReadController.Init,
m.root.Init(),
)
} }
func (m Model) View() string { func (m Model) View() string {

View file

@ -48,11 +48,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.compositor.ClearOverlay() m.compositor.ClearOverlay()
case controllers.ColumnsUpdated: case controllers.ColumnsUpdated:
m.colListModel.refreshTable() m.colListModel.refreshTable()
m.subModel = cc.Collect(m.subModel.Update(msg)) m.subModel = cc.Collect(m.subModel.Update(msg)).(tea.Model)
case tea.KeyMsg: case tea.KeyMsg:
m.compositor = cc.Collect(m.compositor.Update(msg)).(*layout.Compositor) m.compositor = cc.Collect(m.compositor.Update(msg)).(*layout.Compositor)
default: default:
m.subModel = cc.Collect(m.subModel.Update(msg)) m.subModel = cc.Collect(m.subModel.Update(msg)).(tea.Model)
} }
return m, cc.Cmd() return m, cc.Cmd()
} }

View file

@ -5,6 +5,10 @@ type columnModel struct {
} }
func (cm columnModel) Len() int { func (cm columnModel) Len() int {
if len(cm.m.columns) == 0 {
return 0
}
return len(cm.m.columns[cm.m.colOffset:]) + 1 return len(cm.m.columns[cm.m.colOffset:]) + 1
} }

View file

@ -118,6 +118,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *Model) setLeftmostDisplayedColumn(newCol int) { func (m *Model) setLeftmostDisplayedColumn(newCol int) {
if m.columnsProvider == nil || m.columnsProvider.Columns() == nil {
return
}
if newCol < 0 { if newCol < 0 {
m.colOffset = 0 m.colOffset = 0
} else if newCol >= len(m.columnsProvider.Columns().Columns) { } 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 // 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 // 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()) tbl = table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight())
if targetTbl != nil {
tbl.GoBottom()
}
} else { } else {
tbl = *targetTbl tbl = *targetTbl
} }
@ -191,6 +192,7 @@ func (m *Model) rebuildTable(targetTbl *table.Model) {
m.rows = newRows m.rows = newRows
tbl.SetRows(newRows) tbl.SetRows(newRows)
tbl.GoTop() // Preserve top and cursor location
m.table = tbl m.table = tbl
} }
@ -205,6 +207,12 @@ func (m *Model) SelectedItemIndex() int {
func (m *Model) selectedItem() (itemTableRow, bool) { func (m *Model) selectedItem() (itemTableRow, bool) {
resultSet := m.resultSet resultSet := m.resultSet
// Fix bug??
if m.table.Cursor() < 0 {
return itemTableRow{}, false
}
if resultSet != nil && len(m.rows) > 0 { if resultSet != nil && len(m.rows) > 0 {
selectedItem, ok := m.table.SelectedRow().(itemTableRow) selectedItem, ok := m.table.SelectedRow().(itemTableRow)
if ok { if ok {

View file

@ -23,7 +23,7 @@ func (m *Model) Init() tea.Cmd {
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cc utils.CmdCollector 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() return m, cc.Cmd()
} }

View file

@ -39,10 +39,10 @@ func (vb ZStack) Update(msg tea.Msg) (m tea.Model, cmd tea.Cmd) {
// All other messages go to each model // All other messages go to each model
var cc utils.CmdCollector var cc utils.CmdCollector
vb.visibleModel = cc.Collect(vb.visibleModel.Update(msg)) vb.visibleModel = cc.Collect(vb.visibleModel.Update(msg)).(tea.Model)
vb.focusedModel = cc.Collect(vb.focusedModel.Update(msg)) vb.focusedModel = cc.Collect(vb.focusedModel.Update(msg)).(tea.Model)
for i, c := range vb.otherModels { 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() return vb, cc.Cmd()
} }

View file

@ -50,16 +50,16 @@ func (m Modal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg, tea.MouseMsg: case tea.KeyMsg, tea.MouseMsg:
// only notify top level stack // only notify top level stack
if len(m.modeStack) > 0 { 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 { } else {
m.baseMode = cc.Collect(m.baseMode.Update(msg)) m.baseMode = cc.Collect(m.baseMode.Update(msg)).(tea.Model)
} }
default: default:
// notify all modes of other events // notify all modes of other events
// TODO: is this right? // 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 { for i, s := range m.modeStack {
m.modeStack[i] = cc.Collect(s.Update(msg)) m.modeStack[i] = cc.Collect(s.Update(msg)).(tea.Model)
} }
} }

View file

@ -1,6 +1,7 @@
package statusandprompt package statusandprompt
import ( import (
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -18,6 +19,8 @@ type StatusAndPrompt struct {
style Style style Style
modeLine string modeLine string
statusMessage string statusMessage string
spinner spinner.Model
spinnerVisible bool
pendingInput *events.PromptForInputMsg pendingInput *events.PromptForInputMsg
textInput textinput.Model textInput textinput.Model
width int width int
@ -29,11 +32,20 @@ type Style struct {
func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt { func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt {
textInput := textinput.New() 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 { 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) { 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: case events.WrappedStatusMsg:
s.statusMessage = string(msg.Message) s.statusMessage = string(msg.Message)
cc.Add(func() tea.Msg { return msg.Next }) 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: case events.ModeMessage:
s.modeLine = string(msg) s.modeLine = string(msg)
case events.MessageWithStatus: 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)) if s.spinnerVisible {
s.model = newModel.(layout.ResizingModel) s.spinner = cc.Collect(s.spinner.Update(msg)).(spinner.Model)
}
s.model = cc.Collect(s.model.Update(msg)).(layout.ResizingModel)
return s, cc.Cmd() return s, cc.Cmd()
} }
@ -122,5 +144,9 @@ func (s *StatusAndPrompt) viewStatus() string {
statusLine = s.statusMessage statusLine = s.statusMessage
} }
if s.spinnerVisible {
statusLine = lipgloss.JoinHorizontal(lipgloss.Left, s.spinner.View(), " ", statusLine)
}
return lipgloss.JoinVertical(lipgloss.Top, modeLine, statusLine) return lipgloss.JoinVertical(lipgloss.Top, modeLine, statusLine)
} }

View file

@ -4,6 +4,7 @@ import (
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "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/controllers"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame"
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout" "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 var sel controllers.PromptForTableMsg
sel, m.pendingSelection = *m.pendingSelection, nil 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 { if m.pendingSelection != nil {
m.listController = cc.Collect(m.listController.Update(msg)).(listController) 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() return m, cc.Cmd()
} }

View file

@ -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 { if cmd != nil {
c.cmds = append(c.cmds, cmd) c.cmds = append(c.cmds, cmd)
} }

View file

@ -77,7 +77,7 @@ func (c *SSMController) Clone(param models.SSMParameter) tea.Msg {
} }
func (c *SSMController) DeleteParameter(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() ctx := context.Background()
if err := c.service.Delete(ctx, param); err != nil { if err := c.service.Delete(ctx, param); err != nil {
return events.Error(err) return events.Error(err)