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/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,
@ -125,6 +129,8 @@ func main() {
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)

View file

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

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 {
if value == "y" {
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
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)

View file

@ -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 {
return NewJob(c.jobController, "Listing tables…", func(ctx context.Context) (any, error) {
tables, err := c.tableService.ListTables(context.Background())
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)
},
}
}).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 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)
if err != nil {
return events.Error(err)
}
if resultSet != nil {
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 == "" {
return NewJob(c.jobController, "Scanning…", func(ctx context.Context) (*models.ResultSet, error) {
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)
}
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 {
return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) {
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)
}
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 {
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 {
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 err != nil {
return events.Error(err)
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() {
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 {
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 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)
}).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()
}
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 events.Error(err)
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 {

View file

@ -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 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 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
})); err != nil {
return events.Error(err)
}
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch)
}))
return struct{}{}, err
}).OnDone(func(_ struct{}) tea.Msg {
return twc.tableReadControllers.doScan(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/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)

View file

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

View file

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

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 (
"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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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"
@ -18,6 +19,8 @@ type StatusAndPrompt struct {
style Style
modeLine string
statusMessage string
spinner spinner.Model
spinnerVisible bool
pendingInput *events.PromptForInputMsg
textInput textinput.Model
width int
@ -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)
}

View file

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

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 {
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 {
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)