From 54a120342e07c95f2d64b2975ca17b26ad9e81a3 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 Jan 2023 21:46:31 +1100 Subject: [PATCH] Added command history (#45) * Added command line history to the command, query and filter prompts. * Added query planning debugging to the log. * Fixed bug in query expression which was not treating true and false as boolean literals. * Fixed a bug in the query planning logic which was incorrectly determine that an expression of the form sort_key ^= "string", with no partition key, could be executed as a query instead of a scan. --- cmd/dynamo-browse/main.go | 8 ++- cmd/slog-view/main.go | 2 +- cmd/ssm-browse/main.go | 2 +- internal/common/ui/commandctrl/commandctrl.go | 10 +++- .../common/ui/commandctrl/commandctrl_test.go | 11 +++- internal/common/ui/commandctrl/iface.go | 10 ++++ internal/common/ui/events/commands.go | 12 +++-- internal/common/ui/events/errors.go | 2 + internal/dynamo-browse/controllers/columns.go | 2 +- .../dynamo-browse/controllers/tableread.go | 15 +++++- .../controllers/tablewrite_test.go | 9 +++- internal/dynamo-browse/models/query.go | 42 +++++++++++++++- .../dynamo-browse/models/queryexpr/ast.go | 8 ++- .../models/queryexpr/equality.go | 2 +- .../models/queryexpr/expr_test.go | 17 +++++++ .../dynamo-browse/models/queryexpr/values.go | 8 +++ .../providers/inputhistorystore/model.go | 10 ++++ .../providers/inputhistorystore/store.go | 49 ++++++++++++++++++ .../dynamo-browse/services/historyprovider.go | 13 +++++ .../services/inputhistory/iface.go | 8 +++ .../services/inputhistory/iter.go | 49 ++++++++++++++++++ .../services/inputhistory/service.go | 9 ++++ .../dynamo-browse/services/tables/service.go | 7 ++- .../ui/teamodels/statusandprompt/model.go | 50 +++++++++++++++---- .../ui/teamodels/statusandprompt/types.go | 12 +++++ .../ssm-browse/controllers/ssmcontroller.go | 2 +- 26 files changed, 335 insertions(+), 34 deletions(-) create mode 100644 internal/common/ui/commandctrl/iface.go create mode 100644 internal/dynamo-browse/providers/inputhistorystore/model.go create mode 100644 internal/dynamo-browse/providers/inputhistorystore/store.go create mode 100644 internal/dynamo-browse/services/historyprovider.go create mode 100644 internal/dynamo-browse/services/inputhistory/iface.go create mode 100644 internal/dynamo-browse/services/inputhistory/iter.go create mode 100644 internal/dynamo-browse/services/inputhistory/service.go create mode 100644 internal/dynamo-browse/ui/teamodels/statusandprompt/types.go diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 4fafd0d..495d3ae 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -13,8 +13,10 @@ import ( "github.com/lmika/audax/internal/common/workspaces" "github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/audax/internal/dynamo-browse/providers/inputhistorystore" "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/inputhistory" "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" @@ -81,6 +83,7 @@ func main() { dynamoProvider := dynamo.NewProvider(dynamoClient) resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws) settingStore := settingstore.New(ws) + inputHistoryStore := inputhistorystore.NewInputHistoryStore(ws) if *flagRO { if err := settingStore.SetReadOnly(*flagRO); err != nil { @@ -98,10 +101,11 @@ func main() { itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo) scriptManagerService := scriptmanager.New() jobsService := jobs.NewService(eventBus) + inputHistoryService := inputhistory.New(inputHistoryStore) state := controllers.NewState() jobsController := controllers.NewJobsController(jobsService, eventBus, false) - tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, jobsController, eventBus, *flagTable) + tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, jobsController, inputHistoryService, eventBus, *flagTable) tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore) columnsController := controllers.NewColumnsController(eventBus) exportController := controllers.NewExportController(state, columnsController) @@ -112,7 +116,7 @@ func main() { keyBindingService := keybindings_service.NewService(keyBindings) keyBindingController := controllers.NewKeyBindingController(keyBindingService) - commandController := commandctrl.NewCommandController() + commandController := commandctrl.NewCommandController(inputHistoryService) commandController.AddCommandLookupExtension(scriptController) model := ui.NewModel( diff --git a/cmd/slog-view/main.go b/cmd/slog-view/main.go index c94acc3..b866a3a 100644 --- a/cmd/slog-view/main.go +++ b/cmd/slog-view/main.go @@ -32,7 +32,7 @@ func main() { ctrl := controllers.NewLogFileController(service, flag.Arg(0)) - cmdController := commandctrl.NewCommandController() + cmdController := commandctrl.NewCommandController(nil) //cmdController.AddCommands(&commandctrl.CommandList{ // Commands: map[string]commandctrl.Command{ // "cd": func(args []string) tea.Cmd { diff --git a/cmd/ssm-browse/main.go b/cmd/ssm-browse/main.go index f46a133..ca7586d 100644 --- a/cmd/ssm-browse/main.go +++ b/cmd/ssm-browse/main.go @@ -47,7 +47,7 @@ func main() { ctrl := controllers.New(service) - cmdController := commandctrl.NewCommandController() + cmdController := commandctrl.NewCommandController(nil) cmdController.AddCommands(&commandctrl.CommandList{ Commands: map[string]commandctrl.Command{ "cd": func(ec commandctrl.ExecContext, args []string) tea.Msg { diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index fc6c627..6c08f96 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -3,6 +3,7 @@ package commandctrl import ( "bufio" "bytes" + "context" tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" "log" @@ -14,13 +15,17 @@ import ( "github.com/lmika/shellwords" ) +const commandsCategory = "commands" + type CommandController struct { + historyProvider IterProvider commandList *CommandList lookupExtensions []CommandLookupExtension } -func NewCommandController() *CommandController { +func NewCommandController(historyProvider IterProvider) *CommandController { return &CommandController{ + historyProvider: historyProvider, commandList: nil, lookupExtensions: nil, } @@ -37,7 +42,8 @@ func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension func (c *CommandController) Prompt() tea.Msg { return events.PromptForInputMsg{ - Prompt: ":", + Prompt: ":", + History: c.historyProvider.Iter(context.Background(), commandsCategory), OnDone: func(value string) tea.Msg { return c.Execute(value) }, diff --git a/internal/common/ui/commandctrl/commandctrl_test.go b/internal/common/ui/commandctrl/commandctrl_test.go index e842c39..b300418 100644 --- a/internal/common/ui/commandctrl/commandctrl_test.go +++ b/internal/common/ui/commandctrl/commandctrl_test.go @@ -1,7 +1,9 @@ package commandctrl_test import ( + "context" "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/services" "testing" "github.com/lmika/audax/internal/common/ui/commandctrl" @@ -10,7 +12,7 @@ import ( func TestCommandController_Prompt(t *testing.T) { t.Run("prompt user for a command", func(t *testing.T) { - cmd := commandctrl.NewCommandController() + cmd := commandctrl.NewCommandController(mockIterProvider{}) res := cmd.Prompt() @@ -19,3 +21,10 @@ func TestCommandController_Prompt(t *testing.T) { assert.Equal(t, ":", promptForInputMsg.Prompt) }) } + +type mockIterProvider struct { +} + +func (m mockIterProvider) Iter(ctx context.Context, category string) services.HistoryProvider { + return nil +} diff --git a/internal/common/ui/commandctrl/iface.go b/internal/common/ui/commandctrl/iface.go new file mode 100644 index 0000000..671bc74 --- /dev/null +++ b/internal/common/ui/commandctrl/iface.go @@ -0,0 +1,10 @@ +package commandctrl + +import ( + "context" + "github.com/lmika/audax/internal/dynamo-browse/services" +) + +type IterProvider interface { + Iter(ctx context.Context, category string) services.HistoryProvider +} diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index 9f6bb26..19e30bc 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -2,6 +2,7 @@ package events import ( tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/dynamo-browse/services" "log" ) @@ -22,21 +23,22 @@ func SetTeaMessage(event tea.Msg) tea.Cmd { } } -func PromptForInput(prompt string, onDone func(value string) tea.Msg) tea.Msg { +func PromptForInput(prompt string, history services.HistoryProvider, onDone func(value string) tea.Msg) tea.Msg { return PromptForInputMsg{ - Prompt: prompt, - OnDone: onDone, + Prompt: prompt, + History: history, + OnDone: onDone, } } func Confirm(prompt string, onResult func(yes bool) tea.Msg) tea.Msg { - return PromptForInput(prompt, func(value string) tea.Msg { + return PromptForInput(prompt, nil, 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, nil, func(value string) tea.Msg { if value == "y" { return onYes() } diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go index e4077b4..c34d73f 100644 --- a/internal/common/ui/events/errors.go +++ b/internal/common/ui/events/errors.go @@ -2,6 +2,7 @@ package events import ( tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/dynamo-browse/services" ) // Error indicates that an error occurred @@ -21,6 +22,7 @@ type ModeMessage string // PromptForInput indicates that the context is requesting a line of input type PromptForInputMsg struct { Prompt string + History services.HistoryProvider OnDone func(value string) tea.Msg OnCancel func() tea.Msg } diff --git a/internal/dynamo-browse/controllers/columns.go b/internal/dynamo-browse/controllers/columns.go index 490204f..53f3386 100644 --- a/internal/dynamo-browse/controllers/columns.go +++ b/internal/dynamo-browse/controllers/columns.go @@ -67,7 +67,7 @@ func (cc *ColumnsController) onNewResultSet(rs *models.ResultSet, op resultSetUp } func (cc *ColumnsController) AddColumn(afterIndex int) tea.Msg { - return events.PromptForInput("column expr: ", func(value string) tea.Msg { + return events.PromptForInput("column expr: ", nil, func(value string) tea.Msg { colExpr, err := queryexpr.Parse(value) if err != nil { return events.Error(err) diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 1aabff5..5170731 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -11,6 +11,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/models/attrcodec" "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" "github.com/lmika/audax/internal/dynamo-browse/models/serialisable" + "github.com/lmika/audax/internal/dynamo-browse/services/inputhistory" "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/audax/internal/dynamo-browse/services/viewsnapshot" bus "github.com/lmika/events" @@ -42,11 +43,17 @@ const ( MarkOpToggle ) +const ( + queryInputHistoryCategory = "queries" + filterInputHistoryCategory = "filters" +) + type TableReadController struct { tableService TableReadService workspaceService *viewsnapshot.ViewSnapshotService itemRendererService *itemrenderer.Service jobController *JobsController + inputHistoryService *inputhistory.Service eventBus *bus.Bus tableName string loadFromLastView bool @@ -63,6 +70,7 @@ func NewTableReadController( workspaceService *viewsnapshot.ViewSnapshotService, itemRendererService *itemrenderer.Service, jobController *JobsController, + inputHistoryService *inputhistory.Service, eventBus *bus.Bus, tableName string, ) *TableReadController { @@ -72,6 +80,7 @@ func NewTableReadController( workspaceService: workspaceService, itemRendererService: itemRendererService, jobController: jobController, + inputHistoryService: inputHistoryService, eventBus: eventBus, tableName: tableName, mutex: new(sync.Mutex), @@ -136,7 +145,8 @@ func (c *TableReadController) ScanTable(name string) tea.Msg { func (c *TableReadController) PromptForQuery() tea.Msg { return events.PromptForInputMsg{ - Prompt: "query: ", + Prompt: "query: ", + History: c.inputHistoryService.Iter(context.Background(), queryInputHistoryCategory), OnDone: func(value string) tea.Msg { resultSet := c.state.ResultSet() if resultSet == nil { @@ -288,7 +298,8 @@ func (c *TableReadController) Mark(op MarkOp) tea.Msg { func (c *TableReadController) Filter() tea.Msg { return events.PromptForInputMsg{ - Prompt: "filter: ", + Prompt: "filter: ", + History: c.inputHistoryService.Iter(context.Background(), filterInputHistoryCategory), OnDone: func(value string) tea.Msg { resultSet := c.state.ResultSet() if resultSet == nil { diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 8faea23..2dd2269 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -8,8 +8,10 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/audax/internal/dynamo-browse/providers/inputhistorystore" "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/inputhistory" "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/scriptmanager" @@ -600,9 +602,12 @@ func newService(t *testing.T, cfg serviceConfig) *services { resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws) settingStore := settingstore.New(ws) + inputHistoryStore := inputhistorystore.NewInputHistoryStore(ws) + workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer()) scriptService := scriptmanager.New() + inputHistoryService := inputhistory.New(inputHistoryStore) client := testdynamo.SetupTestTable(t, testData) @@ -612,14 +617,14 @@ func newService(t *testing.T, cfg serviceConfig) *services { state := controllers.NewState() jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true) - readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, jobsController, eventBus, cfg.tableName) + readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, jobsController, inputHistoryService, eventBus, cfg.tableName) writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) settingsController := controllers.NewSettingsController(settingStore, eventBus) columnsController := controllers.NewColumnsController(eventBus) exportController := controllers.NewExportController(state, columnsController) scriptController := controllers.NewScriptController(scriptService, readController, settingsController, eventBus) - commandController := commandctrl.NewCommandController() + commandController := commandctrl.NewCommandController(inputHistoryService) commandController.AddCommandLookupExtension(scriptController) if cfg.isReadOnly { diff --git a/internal/dynamo-browse/models/query.go b/internal/dynamo-browse/models/query.go index cecde72..c3f6704 100644 --- a/internal/dynamo-browse/models/query.go +++ b/internal/dynamo-browse/models/query.go @@ -1,8 +1,48 @@ package models -import "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + "github.com/lmika/audax/internal/dynamo-browse/models/itemrender" +) type QueryExecutionPlan struct { CanQuery bool Expression expression.Expression } + +func (qep QueryExecutionPlan) Describe(dp DescribingPrinter) { + if qep.CanQuery { + dp.Println(" execute as: query") + } else { + dp.Println(" execute as: scan") + } + + if keyCond := aws.ToString(qep.Expression.KeyCondition()); keyCond != "" { + dp.Printf(" key condition: %v", keyCond) + } + if cond := aws.ToString(qep.Expression.Condition()); cond != "" { + dp.Printf(" condition: %v", cond) + } + if filter := aws.ToString(qep.Expression.Filter()); filter != "" { + dp.Printf(" filter: %v", filter) + } + if names := qep.Expression.Names(); len(names) > 0 { + dp.Println(" names:") + for k, v := range names { + dp.Printf(" %v = %v", k, v) + } + } + if values := qep.Expression.Values(); len(values) > 0 { + dp.Println(" values:") + for k, v := range values { + r := itemrender.ToRenderer(v) + dp.Printf(" %v (%v) = %v", k, r.TypeName(), r.StringValue()) + } + } +} + +type DescribingPrinter interface { + Println(v ...any) + Printf(format string, v ...any) +} diff --git a/internal/dynamo-browse/models/queryexpr/ast.go b/internal/dynamo-browse/models/queryexpr/ast.go index fde7c46..d38b319 100644 --- a/internal/dynamo-browse/models/queryexpr/ast.go +++ b/internal/dynamo-browse/models/queryexpr/ast.go @@ -81,11 +81,15 @@ type astPlaceholder struct { } type astLiteralValue struct { - StringVal *string `parser:"@String"` - IntVal *int64 `parser:"| @Int"` + StringVal *string `parser:"@String"` + IntVal *int64 `parser:"| @Int"` + TrueBoolValue bool `parser:"| @KwdTrue"` + FalseBoolValue bool `parser:"| @KwdFalse"` } var scanner = lexer.MustSimple([]lexer.SimpleRule{ + {Name: "KwdTrue", Pattern: `true`}, + {Name: "KwdFalse", Pattern: `false`}, {Name: "Eq", Pattern: `=|[\\^]=|[!]=`}, {Name: "Cmp", Pattern: `<[=]?|>[=]?`}, {Name: "String", Pattern: `"(\\"|[^"])*"`}, diff --git a/internal/dynamo-browse/models/queryexpr/equality.go b/internal/dynamo-browse/models/queryexpr/equality.go index 7f1420e..4d3118e 100644 --- a/internal/dynamo-browse/models/queryexpr/equality.go +++ b/internal/dynamo-browse/models/queryexpr/equality.go @@ -194,7 +194,7 @@ func (a irFieldBeginsWith) canBeExecutedAsQuery(info *models.TableInfo, qci *que return false } - if keyName == info.Keys.SortKey { + if keyName == info.Keys.SortKey && qci.hasSeenPrimaryKey(info) { return qci.addKey(info, a.name.keyName()) } diff --git a/internal/dynamo-browse/models/queryexpr/expr_test.go b/internal/dynamo-browse/models/queryexpr/expr_test.go index 953a0ff..a8e2093 100644 --- a/internal/dynamo-browse/models/queryexpr/expr_test.go +++ b/internal/dynamo-browse/models/queryexpr/expr_test.go @@ -143,6 +143,9 @@ func TestModExpr_Query(t *testing.T) { scanCase("when request sk equals something", `sk="something"`, `#0 = :0`, exprNameIsString(0, 0, "sk", "something"), ), + scanCase("when request sk starts with something", `sk^="something"`, `begins_with (#0, :0)`, + exprNameIsString(0, 0, "sk", "something"), + ), scanCase("with not equal", `sk != "something"`, `#0 <> :0`, exprNameIsString(0, 0, "sk", "something"), ), @@ -158,6 +161,14 @@ func TestModExpr_Query(t *testing.T) { scanCase("greater or equal to value", `num >= 100`, `#0 >= :0`, exprNameIsNumber(0, 0, "num", "100"), ), + scanCase("is true", `bool = true`, `#0 = :0`, + exprName(0, "bool"), + exprValueIsBool(0, true), + ), + scanCase("is false", `bool = false`, `#0 = :0`, + exprName(0, "bool"), + exprValueIsBool(0, false), + ), scanCase("with disjunctions", `pk="prefix" or sk="another"`, `(#0 = :0) OR (#1 = :1)`, @@ -846,6 +857,12 @@ func exprValueIsNumber(valIdx int, expected string) func(ss *scanScenario) { } } +func exprValueIsBool(valIdx int, expected bool) func(ss *scanScenario) { + return func(ss *scanScenario) { + ss.expectedValues[fmt.Sprintf(":%d", valIdx)] = &types.AttributeValueMemberBOOL{Value: expected} + } +} + func exprNameIsString(idx, valIdx int, name string, expected string) func(ss *scanScenario) { return func(ss *scanScenario) { ss.expectedNames[fmt.Sprintf("#%d", idx)] = name diff --git a/internal/dynamo-browse/models/queryexpr/values.go b/internal/dynamo-browse/models/queryexpr/values.go index 387f29d..4a81c45 100644 --- a/internal/dynamo-browse/models/queryexpr/values.go +++ b/internal/dynamo-browse/models/queryexpr/values.go @@ -51,6 +51,10 @@ func (a *astLiteralValue) goValue() (any, error) { return s, nil case a.IntVal != nil: return *a.IntVal, nil + case a.TrueBoolValue: + return true, nil + case a.FalseBoolValue: + return false, nil } return nil, errors.New("unrecognised type") } @@ -65,6 +69,10 @@ func (a *astLiteralValue) String() string { return *a.StringVal case a.IntVal != nil: return strconv.FormatInt(*a.IntVal, 10) + case a.TrueBoolValue: + return "true" + case a.FalseBoolValue: + return "false" } return "" } diff --git a/internal/dynamo-browse/providers/inputhistorystore/model.go b/internal/dynamo-browse/providers/inputhistorystore/model.go new file mode 100644 index 0000000..9cbb402 --- /dev/null +++ b/internal/dynamo-browse/providers/inputhistorystore/model.go @@ -0,0 +1,10 @@ +package inputhistorystore + +import "time" + +type inputHistoryItem struct { + ID int `storm:"id,increment"` + Category string `storm:"index"` + Time time.Time + Item string +} diff --git a/internal/dynamo-browse/providers/inputhistorystore/store.go b/internal/dynamo-browse/providers/inputhistorystore/store.go new file mode 100644 index 0000000..14878d0 --- /dev/null +++ b/internal/dynamo-browse/providers/inputhistorystore/store.go @@ -0,0 +1,49 @@ +package inputhistorystore + +import ( + "context" + "github.com/asdine/storm" + "github.com/lmika/audax/internal/common/sliceutils" + "github.com/lmika/audax/internal/common/workspaces" + "github.com/pkg/errors" + "sort" + "time" +) + +const inputHistoryStore = "InputHistoryStore" + +type Store struct { + ws storm.Node +} + +func NewInputHistoryStore(ws *workspaces.Workspace) *Store { + return &Store{ + ws: ws.DB().From(inputHistoryStore), + } +} + +// Items returns all items from a category ordered by the time +func (as *Store) Items(ctx context.Context, category string) ([]string, error) { + var items []inputHistoryItem + if err := as.ws.Find("Category", category, &items); err != nil { + if errors.Is(err, storm.ErrNotFound) { + return nil, nil + } + return nil, err + } + + sort.Slice(items, func(i, j int) bool { + return items[i].Time.Before(items[j].Time) + }) + return sliceutils.Map(items, func(t inputHistoryItem) string { + return t.Item + }), nil +} + +func (as *Store) PutItem(ctx context.Context, category string, item string) error { + return as.ws.Save(&inputHistoryItem{ + Time: time.Now(), + Category: category, + Item: item, + }) +} diff --git a/internal/dynamo-browse/services/historyprovider.go b/internal/dynamo-browse/services/historyprovider.go new file mode 100644 index 0000000..645a6d0 --- /dev/null +++ b/internal/dynamo-browse/services/historyprovider.go @@ -0,0 +1,13 @@ +package services + +type HistoryProvider interface { + // Len returns the number of historical items + Len() int + + // Item returns the historical item at index 'idx', where items are chronologically ordered such that the + // item at 0 is the oldest item. + Item(idx int) string + + // PutItem adds an item to the history + PutItem(item string) +} diff --git a/internal/dynamo-browse/services/inputhistory/iface.go b/internal/dynamo-browse/services/inputhistory/iface.go new file mode 100644 index 0000000..a046604 --- /dev/null +++ b/internal/dynamo-browse/services/inputhistory/iface.go @@ -0,0 +1,8 @@ +package inputhistory + +import "context" + +type HistoryItemStore interface { + Items(ctx context.Context, category string) ([]string, error) + PutItem(ctx context.Context, category string, item string) error +} diff --git a/internal/dynamo-browse/services/inputhistory/iter.go b/internal/dynamo-browse/services/inputhistory/iter.go new file mode 100644 index 0000000..3cb64cc --- /dev/null +++ b/internal/dynamo-browse/services/inputhistory/iter.go @@ -0,0 +1,49 @@ +package inputhistory + +import ( + "context" + "github.com/lmika/audax/internal/dynamo-browse/services" + "log" + "strings" +) + +func (svc *Service) Iter(ctx context.Context, category string) services.HistoryProvider { + items, err := svc.store.Items(ctx, category) + if err != nil { + log.Printf("warn: cannot get iter for '%v': %v", category, err) + return nil + } + return &Iter{svc, items, category} +} + +func (svc *Service) PutItem(ctx context.Context, category string, value string) error { + return svc.store.PutItem(ctx, category, value) +} + +type Iter struct { + svc *Service + items []string + category string +} + +func (i *Iter) Len() int { + return len(i.items) +} + +func (i *Iter) Item(idx int) string { + return i.items[idx] +} + +func (i *Iter) PutItem(item string) { + if strings.TrimSpace(item) == "" { + return + } + + if len(i.items) > 0 && i.items[len(i.items)-1] == item { + return + } + + if err := i.svc.PutItem(context.Background(), i.category, item); err != nil { + log.Printf("warn: cannot put input history: category = %v, value = %v, err = %v", i.category, item, err) + } +} diff --git a/internal/dynamo-browse/services/inputhistory/service.go b/internal/dynamo-browse/services/inputhistory/service.go new file mode 100644 index 0000000..4bcd23d --- /dev/null +++ b/internal/dynamo-browse/services/inputhistory/service.go @@ -0,0 +1,9 @@ +package inputhistory + +type Service struct { + store HistoryItemStore +} + +func New(store HistoryItemStore) *Service { + return &Service{store: store} +} diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index 154a5ad..7cf6612 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -59,15 +59,18 @@ func (s *Service) doScan( runAsQuery = plan.CanQuery filterExpr = &plan.Expression + + log.Printf("Running query over '%v'", tableInfo.Name) + plan.Describe(log.Default()) + } else { + log.Printf("Performing scan over '%v'", tableInfo.Name) } var results []models.Item var lastEvalKey map[string]types.AttributeValue if runAsQuery { - log.Printf("executing query") results, lastEvalKey, err = s.provider.QueryItems(ctx, tableInfo.Name, filterExpr, exclusiveStartKey, limit) } else { - log.Printf("executing scan") results, lastEvalKey, err = s.provider.ScanItems(ctx, tableInfo.Name, filterExpr, exclusiveStartKey, limit) } diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index 1cae636..c25c560 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -20,7 +20,7 @@ type StatusAndPrompt struct { statusMessage string spinner spinner.Model spinnerVisible bool - pendingInput *events.PromptForInputMsg + pendingInput *pendingInputState textInput textinput.Model width, height int lastModeLineHeight int @@ -83,15 +83,15 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.textInput.Prompt = msg.Prompt s.textInput.Focus() s.textInput.SetValue("") - s.pendingInput = &msg + s.pendingInput = newPendingInputState(msg) case tea.KeyMsg: if s.pendingInput != nil { switch msg.Type { case tea.KeyCtrlC, tea.KeyEsc: - if s.pendingInput.OnCancel != nil { + if s.pendingInput.originalMsg.OnCancel != nil { pendingInput := s.pendingInput cc.Add(func() tea.Msg { - m := pendingInput.OnCancel() + m := pendingInput.originalMsg.OnCancel() return m }) } @@ -100,18 +100,48 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { pendingInput := s.pendingInput s.pendingInput = nil - return s, func() tea.Msg { - m := pendingInput.OnDone(s.textInput.Value()) - return m + m := pendingInput.originalMsg.OnDone(s.textInput.Value()) + + return s, tea.Batch( + events.SetTeaMessage(m), + func() tea.Msg { + if historyProvider := pendingInput.originalMsg.History; historyProvider != nil { + if _, isErrMsg := m.(events.ErrorMsg); !isErrMsg { + historyProvider.PutItem(s.textInput.Value()) + } + } + return nil + }, + ) + case tea.KeyUp: + if historyProvider := s.pendingInput.originalMsg.History; historyProvider != nil && historyProvider.Len() > 0 { + if s.pendingInput.historyIdx < 0 { + s.pendingInput.historyIdx = historyProvider.Len() - 1 + } else if s.pendingInput.historyIdx > 0 { + s.pendingInput.historyIdx -= 1 + } else { + s.pendingInput.historyIdx = 0 + } + s.textInput.SetValue(historyProvider.Item(s.pendingInput.historyIdx)) + s.textInput.SetCursor(len(s.textInput.Value())) + } + case tea.KeyDown: + if historyProvider := s.pendingInput.originalMsg.History; historyProvider != nil && historyProvider.Len() > 0 { + if s.pendingInput.historyIdx >= 0 && s.pendingInput.historyIdx < historyProvider.Len()-1 { + s.pendingInput.historyIdx += 1 + } + s.textInput.SetValue(historyProvider.Item(s.pendingInput.historyIdx)) + s.textInput.SetCursor(len(s.textInput.Value())) } default: if msg.Type == tea.KeyRunes { msg.Runes = sliceutils.Filter(msg.Runes, func(r rune) bool { return r != '\x0d' && r != '\x0a' }) } - newTextInput, cmd := s.textInput.Update(msg) - s.textInput = newTextInput - return s, cmd } + + newTextInput, cmd := s.textInput.Update(msg) + s.textInput = newTextInput + return s, cmd } else { s.statusMessage = "" } diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/types.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/types.go new file mode 100644 index 0000000..10c766c --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/types.go @@ -0,0 +1,12 @@ +package statusandprompt + +import "github.com/lmika/audax/internal/common/ui/events" + +type pendingInputState struct { + originalMsg events.PromptForInputMsg + historyIdx int +} + +func newPendingInputState(msg events.PromptForInputMsg) *pendingInputState { + return &pendingInputState{originalMsg: msg, historyIdx: -1} +} diff --git a/internal/ssm-browse/controllers/ssmcontroller.go b/internal/ssm-browse/controllers/ssmcontroller.go index 1ecfeab..52a2bea 100644 --- a/internal/ssm-browse/controllers/ssmcontroller.go +++ b/internal/ssm-browse/controllers/ssmcontroller.go @@ -56,7 +56,7 @@ func (c *SSMController) ChangePrefix(newPrefix string) tea.Msg { } func (c *SSMController) Clone(param models.SSMParameter) tea.Msg { - return events.PromptForInput("New key: ", func(value string) tea.Msg { + return events.PromptForInput("New key: ", nil, func(value string) tea.Msg { return func() tea.Msg { ctx := context.Background() if err := c.service.Clone(ctx, param, value); err != nil {