diff --git a/.gitignore b/.gitignore index b14c548..2b59837 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ debug.log +.DS_store +.idea diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 3a89f66..7b11624 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" "log" "net" "os" @@ -159,7 +160,11 @@ func main() { keyBindingService := keybindings_service.NewService(keyBindings) keyBindingController := controllers.NewKeyBindingController(keyBindingService, scriptController) - commandController := commandctrl.NewCommandController(inputHistoryService) + commandController := commandctrl.NewCommandController(inputHistoryService, + cmdpacks.StandardCommands{ + ReadController: tableReadController, + }, + ) commandController.AddCommandLookupExtension(scriptController) commandController.SetCommandCompletionProvider(columnsController) diff --git a/go.mod b/go.mod index 2eb4b60..4f87cec 100644 --- a/go.mod +++ b/go.mod @@ -117,5 +117,5 @@ require ( golang.org/x/text v0.9.0 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281 // indirect + ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8 // indirect ) diff --git a/go.sum b/go.sum index 2286e36..f26fcfa 100644 --- a/go.sum +++ b/go.sum @@ -436,3 +436,5 @@ ucl.lmika.dev v0.0.0-20240504001444-cf3a12bf0d4d h1:OqGmR0Y+OG6aFIOlXy2QwEHtuUNa ucl.lmika.dev v0.0.0-20240504001444-cf3a12bf0d4d/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281 h1:/M7phiv/0XVp3wKkOxEnGQysf8+RS6NOaBQZyUEoSsA= ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= +ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8 h1:vWttdW8GJWcTUQeJFbQHqCHJDLFWQ9nccUTx/lW2v8s= +ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8/go.mod h1:FMP2ncSu4UxfvB0iA2zlebwL+1UPCARdyYNOrmi86A4= diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go new file mode 100644 index 0000000..610d750 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -0,0 +1,60 @@ +package cmdpacks + +import ( + "context" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" + "ucl.lmika.dev/repl" + "ucl.lmika.dev/ucl" +) + +type StandardCommands struct { + ReadController *controllers.TableReadController +} + +var cmdQuitDoc = repl.Doc{ + Brief: "Quits dynamo-browse", + Usage: "quit", + Detailed: ` + This will quit dynamo-browse immediately, without prompting to apply + any changes. + `, +} + +func (sc StandardCommands) cmdQuit(ctx context.Context, args ucl.CallArgs) (any, error) { + commandctrl.PostMsg(ctx, tea.Quit) + return nil, nil +} + +var cmdTableDoc = repl.Doc{ + Brief: "Prompt for table to scan", + Usage: "table [NAME]", + Args: []repl.ArgDoc{ + {Name: "name", Brief: "Name of the table to scan"}, + }, + Detailed: ` + If called with an argument, it will scan the table with that name and + replace the current result set. If called without an argument, it will + prompt for a table to scan. + + This command is intended only for interactive sessions and is not suitable + for scripting. The scan or table prompts will happen asynchronously. + `, +} + +func (sc StandardCommands) cmdTable(ctx context.Context, args ucl.CallArgs) (any, error) { + var tableName string + if err := args.Bind(&tableName); err == nil { + commandctrl.PostMsg(ctx, sc.ReadController.ScanTable(tableName)) + return nil, nil + } + + commandctrl.PostMsg(ctx, sc.ReadController.ListTables(false)) + return nil, nil +} + +func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { + ucl.SetBuiltin("quit", sc.cmdQuit) + ucl.SetBuiltin("table", sc.cmdTable) +} diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 988021e..8c110a4 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -2,6 +2,7 @@ package commandctrl import ( "context" + "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" "log" @@ -17,30 +18,42 @@ import ( const commandsCategory = "commands" +type cmdMessage struct { + cmd string +} + type CommandController struct { uclInst *ucl.Inst historyProvider IterProvider commandList *CommandList lookupExtensions []CommandLookupExtension completionProvider CommandCompletionProvider + cmdChan chan cmdMessage msgChan chan tea.Msg interactive bool } -func NewCommandController(historyProvider IterProvider) *CommandController { +func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) *CommandController { cc := &CommandController{ historyProvider: historyProvider, commandList: nil, lookupExtensions: nil, + cmdChan: make(chan cmdMessage), msgChan: make(chan tea.Msg), interactive: true, } cc.uclInst = ucl.New( ucl.WithOut(ucl.LineHandler(cc.printLine)), - ucl.WithMissingBuiltinHandler(cc.cmdInvoker), ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.FS(nil)), ) + + for _, pkg := range pkgs { + pkg.ConfigureUCL(cc.uclInst) + } + + go cc.cmdLooper() + return cc } @@ -101,17 +114,42 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms return nil } - res, err := c.uclInst.Eval(context.Background(), commandInput) - if err != nil { - return events.Error(err) + select { + case c.cmdChan <- cmdMessage{cmd: input}: + // good + default: + return events.Error(errors.New("command currently running")) } - if teaMsg, ok := res.(teaMsgWrapper); ok { - return teaMsg.msg - } + /* + res, err := c.uclInst.Eval(context.Background(), commandInput) + if err != nil { + return events.Error(err) + } + + if teaMsg, ok := res.(teaMsgWrapper); ok { + return teaMsg.msg + } + */ return nil } +func (c *CommandController) cmdLooper() { + ctx := context.WithValue(context.Background(), commandCtlKey, c) + + for { + select { + case cmdChan := <-c.cmdChan: + res, err := c.uclInst.Eval(ctx, cmdChan.cmd) + if err != nil { + c.postMessage(events.Error(err)) + } else if res != nil { + c.postMessage(events.StatusMsg(fmt.Sprint(res))) + } + } + } +} + func (c *CommandController) Alias(commandName string) Command { return func(ctx ExecContext, args ucl.CallArgs) tea.Msg { command := c.lookupCommand(commandName) @@ -158,13 +196,13 @@ func (c *CommandController) ExecuteFile(filename string) error { } func (c *CommandController) executeFile(file []byte, filename string) error { - msg := c.execute(ExecContext{FromFile: true}, string(file)) - switch m := msg.(type) { - case events.ErrorMsg: - log.Printf("%v: error - %v", filename, m.Error()) - case events.StatusMsg: - log.Printf("%v: %v", filename, string(m)) - } + //msg := c.execute(ExecContext{FromFile: true}, string(file)) + //switch m := msg.(type) { + //case events.ErrorMsg: + // log.Printf("%v: error - %v", filename, m.Error()) + //case events.StatusMsg: + // log.Printf("%v: %v", filename, string(m)) + //} return nil } @@ -194,6 +232,14 @@ func (c *CommandController) printLine(s string) { } } +func (c *CommandController) postMessage(msg tea.Msg) { + if c.msgChan == nil { + return + } + + c.msgChan <- msg +} + type teaMsgWrapper struct { msg tea.Msg } diff --git a/internal/common/ui/commandctrl/ctx.go b/internal/common/ui/commandctrl/ctx.go new file mode 100644 index 0000000..a041e33 --- /dev/null +++ b/internal/common/ui/commandctrl/ctx.go @@ -0,0 +1,17 @@ +package commandctrl + +import ( + "context" + tea "github.com/charmbracelet/bubbletea" +) + +type commandCtlKeyType struct{} + +var commandCtlKey = commandCtlKeyType{} + +func PostMsg(ctx context.Context, msg tea.Msg) { + cmdCtl, ok := ctx.Value(commandCtlKey).(*CommandController) + if ok { + cmdCtl.postMessage(msg) + } +} diff --git a/internal/common/ui/commandctrl/packs.go b/internal/common/ui/commandctrl/packs.go new file mode 100644 index 0000000..76d0bd7 --- /dev/null +++ b/internal/common/ui/commandctrl/packs.go @@ -0,0 +1,7 @@ +package commandctrl + +import "ucl.lmika.dev/ucl" + +type CommandPack interface { + ConfigureUCL(ucl *ucl.Inst) +} diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index ac6d873..d6c0a8e 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -1,16 +1,11 @@ package ui import ( - "log" - "os" - "ucl.lmika.dev/ucl" - "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "github.com/lmika/dynamo-browse/internal/common/ui/events" "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings" @@ -26,7 +21,8 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/tableselect" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils" bus "github.com/lmika/events" - "github.com/pkg/errors" + "log" + "os" ) const ( @@ -94,162 +90,164 @@ func NewModel( dialogPrompt := dialogprompt.New(statusAndPrompt) tableSelect := tableselect.New(dialogPrompt, uiStyles) - cc.AddCommands(&commandctrl.CommandList{ - Commands: map[string]commandctrl.Command{ - "quit": commandctrl.NoArgCommand(tea.Quit), - "table": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var tableName string - if err := args.Bind(&tableName); err == nil { - return rc.ScanTable(tableName) - } - - return rc.ListTables(false) - }, - "export": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var filename string - if err := args.Bind(&filename); err != nil { - return events.Error(errors.New("expected filename")) - } - - opts := controllers.ExportOptions{ - AllResults: args.HasSwitch("all"), - } - - return exportController.ExportCSV(filename, opts) - }, - "mark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var markOp = controllers.MarkOpMark - - 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 events.Error(errors.New("unrecognised mark operation")) + /* + cc.AddCommands(&commandctrl.CommandList{ + Commands: map[string]commandctrl.Command{ + "quit": commandctrl.NoArgCommand(tea.Quit), + "table": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var tableName string + if err := args.Bind(&tableName); err == nil { + return rc.ScanTable(tableName) } - } - var whereExpr = "" - _ = args.BindSwitch("where", &whereExpr) - - return rc.Mark(markOp, whereExpr) - }, - "unmark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - return rc.Mark(controllers.MarkOpUnmark, "") - }, - "next-page": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - return rc.NextPage() - }, - "delete": commandctrl.NoArgCommand(wc.DeleteMarked), - - // TEMP - "new-item": commandctrl.NoArgCommand(wc.NewItem), - "clone": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - return wc.CloneItem(dtv.SelectedItemIndex()) - }, - "set-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var fieldName string - if err := args.Bind(&fieldName); err != nil { - return events.Error(errors.New("expected field")) - } - - 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 - } - - return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, fieldName) - }, - "del-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var fieldName string - // TODO: support rest args - if err := args.Bind(&fieldName); err != nil { - return events.Error(errors.New("expected field")) - } - - return wc.DeleteAttribute(dtv.SelectedItemIndex(), fieldName) - }, - - "put": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - return wc.PutItems() - }, - "touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - return wc.TouchItem(dtv.SelectedItemIndex()) - }, - "noisy-touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - return wc.NoisyTouchItem(dtv.SelectedItemIndex()) - }, - - /* - "echo": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - s := new(strings.Builder) - for _, arg := range args { - s.WriteString(arg) - } - return events.StatusMsg(s.String()) + return rc.ListTables(false) }, - */ - "set-opt": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var name string - if err := args.Bind(&name); err != nil { - return events.Error(errors.New("expected settingName")) - } + "export": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var filename string + if err := args.Bind(&filename); err != nil { + return events.Error(errors.New("expected filename")) + } - var value string - if err := args.Bind(&value); err == nil { - return settingsController.SetSetting(name, value) - } + opts := controllers.ExportOptions{ + AllResults: args.HasSwitch("all"), + } - return settingsController.SetSetting(name, "") + return exportController.ExportCSV(filename, opts) + }, + "mark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var markOp = controllers.MarkOpMark + + 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 events.Error(errors.New("unrecognised mark operation")) + } + } + + var whereExpr = "" + _ = args.BindSwitch("where", &whereExpr) + + return rc.Mark(markOp, whereExpr) + }, + "unmark": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return rc.Mark(controllers.MarkOpUnmark, "") + }, + "next-page": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return rc.NextPage() + }, + "delete": commandctrl.NoArgCommand(wc.DeleteMarked), + + // TEMP + "new-item": commandctrl.NoArgCommand(wc.NewItem), + "clone": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return wc.CloneItem(dtv.SelectedItemIndex()) + }, + "set-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var fieldName string + if err := args.Bind(&fieldName); err != nil { + return events.Error(errors.New("expected field")) + } + + 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 + } + + return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, fieldName) + }, + "del-attr": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var fieldName string + // TODO: support rest args + if err := args.Bind(&fieldName); err != nil { + return events.Error(errors.New("expected field")) + } + + return wc.DeleteAttribute(dtv.SelectedItemIndex(), fieldName) + }, + + "put": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return wc.PutItems() + }, + "touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return wc.TouchItem(dtv.SelectedItemIndex()) + }, + "noisy-touch": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + return wc.NoisyTouchItem(dtv.SelectedItemIndex()) + }, + + + "echo": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + s := new(strings.Builder) + for _, arg := range args { + s.WriteString(arg) + } + return events.StatusMsg(s.String()) + }, + "set-opt": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var name string + if err := args.Bind(&name); err != nil { + return events.Error(errors.New("expected settingName")) + } + + var value string + if err := args.Bind(&value); err == nil { + return settingsController.SetSetting(name, value) + } + + return settingsController.SetSetting(name, "") + }, + "rebind": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var bindingName, newKey string + if err := args.Bind(&bindingName, &newKey); err != nil { + return events.Error(errors.New("expected: bindingName newKey")) + } + + return keyBindingController.Rebind(bindingName, newKey, ctx.FromFile) + }, + + "run-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var name string + if err := args.Bind(&name); err != nil { + return events.Error(errors.New("expected: script name")) + } + + return scriptController.RunScript(name) + }, + "load-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { + var name string + if err := args.Bind(&name); err != nil { + return events.Error(errors.New("expected: script name")) + } + + return scriptController.LoadScript(name) + }, + + // Aliases + "sa": cc.Alias("set-attr"), + "da": cc.Alias("del-attr"), + "np": cc.Alias("next-page"), + "w": cc.Alias("put"), + "q": cc.Alias("quit"), }, - "rebind": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var bindingName, newKey string - if err := args.Bind(&bindingName, &newKey); err != nil { - return events.Error(errors.New("expected: bindingName newKey")) - } + }) - return keyBindingController.Rebind(bindingName, newKey, ctx.FromFile) - }, - - "run-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var name string - if err := args.Bind(&name); err != nil { - return events.Error(errors.New("expected: script name")) - } - - return scriptController.RunScript(name) - }, - "load-script": func(ctx commandctrl.ExecContext, args ucl.CallArgs) tea.Msg { - var name string - if err := args.Bind(&name); err != nil { - return events.Error(errors.New("expected: script name")) - } - - return scriptController.LoadScript(name) - }, - - // Aliases - "sa": cc.Alias("set-attr"), - "da": cc.Alias("del-attr"), - "np": cc.Alias("next-page"), - "w": cc.Alias("put"), - "q": cc.Alias("quit"), - }, - }) + */ root := layout.FullScreen(tableSelect)