diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 7b11624..820abc0 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -162,7 +162,9 @@ func main() { commandController := commandctrl.NewCommandController(inputHistoryService, cmdpacks.StandardCommands{ - ReadController: tableReadController, + ReadController: tableReadController, + WriteController: tableWriteController, + ExportController: exportController, }, ) commandController.AddCommandLookupExtension(scriptController) @@ -183,6 +185,7 @@ func main() { pasteboardProvider, keyBindings, ) + commandController.SetUIStateProvider(&model) // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. osstyle.DetectCurrentScheme() diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index 610d750..63cbcfa 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -5,17 +5,21 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/pkg/errors" "ucl.lmika.dev/repl" "ucl.lmika.dev/ucl" ) type StandardCommands struct { - ReadController *controllers.TableReadController + ReadController *controllers.TableReadController + WriteController *controllers.TableWriteController + ExportController *controllers.ExportController + KeyBindingController *controllers.KeyBindingController } var cmdQuitDoc = repl.Doc{ Brief: "Quits dynamo-browse", - Usage: "quit", Detailed: ` This will quit dynamo-browse immediately, without prompting to apply any changes. @@ -29,7 +33,7 @@ func (sc StandardCommands) cmdQuit(ctx context.Context, args ucl.CallArgs) (any, var cmdTableDoc = repl.Doc{ Brief: "Prompt for table to scan", - Usage: "table [NAME]", + Usage: "[NAME]", Args: []repl.ArgDoc{ {Name: "name", Brief: "Name of the table to scan"}, }, @@ -54,7 +58,321 @@ func (sc StandardCommands) cmdTable(ctx context.Context, args ucl.CallArgs) (any return nil, nil } +var cmdExportDoc = repl.Doc{ + Brief: "Exports a result-set as CSV", + Usage: "FILENAME [-all]", + Args: []repl.ArgDoc{ + {Name: "filename", Brief: "Filename to export to"}, + {Name: "-all", Brief: "Export all results from the table"}, + }, + Detailed: ` + The fields of the current table view will be treated as the header of the + exported table. + + This command is intended only for interactive sessions and is not suitable + for scripting. The export will run asynchronously. + `, +} + +func (sc StandardCommands) cmdExport(ctx context.Context, args ucl.CallArgs) (any, error) { + var filename string + if err := args.Bind(&filename); err != nil { + return nil, errors.New("expected filename") + } + + opts := controllers.ExportOptions{ + AllResults: args.HasSwitch("all"), + } + + commandctrl.PostMsg(ctx, sc.ExportController.ExportCSV(filename, opts)) + return nil, nil +} + +var cmdMarkDoc = repl.Doc{ + Brief: "Set the marked items of the current result-set", + Usage: "[WHAT] [-where EXPR]", + Args: []repl.ArgDoc{ + {Name: "what", Brief: "Items to mark. Defaults to 'all'"}, + {Name: "-where", Brief: "Filter expression select items to mark"}, + }, + Detailed: ` + WHAT can be one of: + + - all: Mark all items in the current result-set + - none: Unmark all items in the current result-set + - toggle: Toggle the marked state of all items in the current result-set + `, +} + +func (sc StandardCommands) cmdMark(ctx context.Context, args ucl.CallArgs) (any, error) { + var markOp = controllers.MarkOpMark + + if args.NArgs() > 0 { + var markOpStr string + if err := args.Bind(&markOpStr); err == nil { + switch markOpStr { + case "all": + markOp = controllers.MarkOpMark + case "none": + markOp = controllers.MarkOpUnmark + case "toggle": + markOp = controllers.MarkOpToggle + default: + return nil, errors.New("unrecognised mark operation") + } + } + } + + var whereExpr = "" + if args.HasSwitch("where") { + if err := args.BindSwitch("where", &whereExpr); err != nil { + return nil, err + } + } + + commandctrl.PostMsg(ctx, sc.ReadController.Mark(markOp, whereExpr)) + return nil, nil +} + +var cmdNextPageDoc = repl.Doc{ + Brief: "Retrieve and display the next page of the current result-set", + Detailed: ` + This command is intended only for interactive sessions and is not suitable + for scripting. Fetching the next page will run asynchronously. + `, +} + +func (sc StandardCommands) cmdNextPage(ctx context.Context, args ucl.CallArgs) (any, error) { + commandctrl.PostMsg(ctx, sc.ReadController.NextPage()) + return nil, nil +} + +var cmdDeleteDoc = repl.Doc{ + Brief: "Delete the marked items of the current result-set", + Detailed: ` + The user will be prompted to confirm the deletion. If approved, the + items will be deleted immediately. + + This command is intended only for interactive sessions and is not suitable + for scripting. + `, +} + +func (sc StandardCommands) cmdDelete(ctx context.Context, args ucl.CallArgs) (any, error) { + commandctrl.PostMsg(ctx, sc.WriteController.DeleteMarked()) + return nil, nil +} + +var cmdNewItemDoc = repl.Doc{ + Brief: "Adds a new item to the current result-set", + Detailed: ` + The user will be prompted to enter the values for each required attribute, + such as the partition and sort key. The new item will be commited to the database + upon the next write. + + This command is intended only for interactive sessions and is not suitable + for scripting. + `, +} + +func (sc StandardCommands) cmdNewItem(ctx context.Context, args ucl.CallArgs) (any, error) { + commandctrl.PostMsg(ctx, sc.WriteController.NewItem()) + return nil, nil +} + +var cmdCloneDoc = repl.Doc{ + Brief: "Adds a copy of the selected item as a new item to the current result-set", + Detailed: ` + The user will be prompted to enter the partition and sort key. All other + attributes will be cloned from the selected item. The new item will be + commited to the database upon the next write. + + This command is intended only for interactive sessions and is not suitable + for scripting. + `, +} + +func (sc StandardCommands) cmdClone(ctx context.Context, args ucl.CallArgs) (any, error) { + selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) + if !ok { + return nil, errors.New("no item selected") + } + + commandctrl.PostMsg(ctx, sc.WriteController.CloneItem(selectedItemIndex)) + return nil, nil +} + +var cmdSetAttrDoc = repl.Doc{ + Brief: "Modify a field value of the selected or marked items", + Usage: "ATTR [TYPE]", + Args: []repl.ArgDoc{ + {Name: "attr", Brief: "Attribute to modify"}, + {Name: "-S", Brief: "Set attribute to a string"}, + {Name: "-N", Brief: "Set attribute to a number"}, + {Name: "-BOOL", Brief: "Set attribute to a boolean"}, + {Name: "-NULL", Brief: "Set attribute to a null"}, + {Name: "-TO", Brief: "Set attribute to the result of an expression"}, + }, + Detailed: ` + The user will be prompted to enter the new value for the attribute. + If the attribute type is not known, then a type will need to be specified. + Otherwise, the type will be unchanged. The modified item will be + commited to the database upon the next write. + `, +} + +func (sc StandardCommands) cmdSetAttr(ctx context.Context, args ucl.CallArgs) (any, error) { + var fieldName string + if err := args.Bind(&fieldName); err != nil { + return nil, errors.New("expected field name") + } + + selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) + if !ok { + return nil, errors.New("no item selected") + } + + var itemType = models.UnsetItemType + switch { + case args.HasSwitch("S"): + itemType = models.StringItemType + case args.HasSwitch("N"): + itemType = models.NumberItemType + case args.HasSwitch("BOOL"): + itemType = models.BoolItemType + case args.HasSwitch("NULL"): + itemType = models.NullItemType + case args.HasSwitch("TO"): + itemType = models.ExprValueItemType + } + + commandctrl.PostMsg(ctx, sc.WriteController.SetAttributeValue(selectedItemIndex, itemType, fieldName)) + return nil, nil +} + +var cmdDelAttrDoc = repl.Doc{ + Brief: "Remove the field of the selected or marked items", + Usage: "ATTR", + Args: []repl.ArgDoc{ + {Name: "attr", Brief: "Attribute to remove"}, + }, + Detailed: ` + The modified item will be commited to the database upon the next write. + `, +} + +func (sc StandardCommands) cmdDelAttr(ctx context.Context, args ucl.CallArgs) (any, error) { + var fieldName string + if err := args.Bind(&fieldName); err != nil { + return nil, errors.New("expected field name") + } + + selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) + if !ok { + return nil, errors.New("no item selected") + } + + commandctrl.PostMsg(ctx, sc.WriteController.DeleteAttribute(selectedItemIndex, fieldName)) + return nil, nil +} + +var cmdPutDoc = repl.Doc{ + Brief: "Commit changes to the table", + Detailed: ` + This will put all new and modified items. + + This command is intended only for interactive sessions and is not suitable + for scripting. The user will be prompted to confirm the changes. + `, +} + +func (sc StandardCommands) cmdPut(ctx context.Context, args ucl.CallArgs) (any, error) { + commandctrl.PostMsg(ctx, sc.WriteController.PutItems()) + return nil, nil +} + +var cmdTouchDoc = repl.Doc{ + Brief: "Put the currently selected item", + Detailed: ` + This will put the currently selected item, regardless of whether it has been + modified. + + This command is intended only for interactive sessions and is not suitable + for scripting. The user will be prompted to confirm the touch. + `, +} + +func (sc StandardCommands) cmdTouch(ctx context.Context, args ucl.CallArgs) (any, error) { + selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) + if !ok { + return nil, errors.New("no item selected") + } + + commandctrl.PostMsg(ctx, sc.WriteController.TouchItem(selectedItemIndex)) + return nil, nil +} + +var cmdNoisyTouchDoc = repl.Doc{ + Brief: "Put the currently selected item by deleting it first", + Detailed: ` + This will put the currently selected item, regardless of whether it has been + modified. It does so by removing the item from the table, then adding it back again. + + This command is intended only for interactive sessions and is not suitable + for scripting. The user will be prompted to confirm the touch. + `, +} + +func (sc StandardCommands) cmdNoisyTouch(ctx context.Context, args ucl.CallArgs) (any, error) { + selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) + if !ok { + return nil, errors.New("no item selected") + } + + commandctrl.PostMsg(ctx, sc.WriteController.NoisyTouchItem(selectedItemIndex)) + return nil, nil +} + +var cmdRebindDoc = repl.Doc{ + Brief: "Binds a new key to an action", + Usage: "ACTION KEY", + Args: []repl.ArgDoc{ + {Name: "action", Brief: "Action to bind"}, + {Name: "key", Brief: "Key to bind"}, + }, + Detailed: ` + If the key is already bound to an action, it will be replaced. + The set of actions this command accepts is well-defined. For binding + to arbitrary actions, use the ui:bind command. + `, +} + +func (sc StandardCommands) cmdRebind(ctx context.Context, args ucl.CallArgs) (any, error) { + var bindingName, newKey string + if err := args.Bind(&bindingName, &newKey); err != nil { + return nil, errors.New("expected: bindingName newKey") + } + + // TODO: should only force if not interactive + commandctrl.PostMsg(ctx, sc.KeyBindingController.Rebind(bindingName, newKey, false)) + return nil, nil +} + func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { ucl.SetBuiltin("quit", sc.cmdQuit) ucl.SetBuiltin("table", sc.cmdTable) + ucl.SetBuiltin("export", sc.cmdExport) + ucl.SetBuiltin("mark", sc.cmdMark) + // unmark --> alias for { mark none } + ucl.SetBuiltin("next-page", sc.cmdNextPage) + ucl.SetBuiltin("delete", sc.cmdDelete) + ucl.SetBuiltin("new-item", sc.cmdNewItem) + ucl.SetBuiltin("clone", sc.cmdClone) + ucl.SetBuiltin("set-attr", sc.cmdSetAttr) + ucl.SetBuiltin("del-attr", sc.cmdDelAttr) + ucl.SetBuiltin("put", sc.cmdPut) + ucl.SetBuiltin("touch", sc.cmdTouch) + ucl.SetBuiltin("noisy-touch", sc.cmdNoisyTouch) + ucl.SetBuiltin("rebind", sc.cmdRebind) + // set-opt --> alias to opts:set } diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 8c110a4..99bec81 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -28,6 +28,7 @@ type CommandController struct { commandList *CommandList lookupExtensions []CommandLookupExtension completionProvider CommandCompletionProvider + uiStateProvider UIStateProvider cmdChan chan cmdMessage msgChan chan tea.Msg interactive bool @@ -68,6 +69,10 @@ func (c *CommandController) StartMessageSender(msgSender func(tea.Msg)) { } } +func (c *CommandController) SetUIStateProvider(provider UIStateProvider) { + c.uiStateProvider = provider +} + func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) { c.lookupExtensions = append(c.lookupExtensions, ext) } diff --git a/internal/common/ui/commandctrl/ctx.go b/internal/common/ui/commandctrl/ctx.go index a041e33..7c6f193 100644 --- a/internal/common/ui/commandctrl/ctx.go +++ b/internal/common/ui/commandctrl/ctx.go @@ -15,3 +15,12 @@ func PostMsg(ctx context.Context, msg tea.Msg) { cmdCtl.postMessage(msg) } } + +func SelectedItemIndex(ctx context.Context) (int, bool) { + cmdCtl, ok := ctx.Value(commandCtlKey).(*CommandController) + if !ok { + return 0, false + } + + return cmdCtl.uiStateProvider.SelectedItemIndex(), true +} diff --git a/internal/common/ui/commandctrl/iface.go b/internal/common/ui/commandctrl/iface.go index 1cb834a..41d3a7f 100644 --- a/internal/common/ui/commandctrl/iface.go +++ b/internal/common/ui/commandctrl/iface.go @@ -8,3 +8,7 @@ import ( type IterProvider interface { Iter(ctx context.Context, category string) services.HistoryProvider } + +type UIStateProvider interface { + SelectedItemIndex() int +} diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index d6c0a8e..d57c816 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -393,3 +393,7 @@ func (m *Model) promptToQuit() tea.Msg { return nil }) } + +func (m *Model) SelectedItemIndex() int { + return m.tableView.SelectedItemIndex() +}