From 29d425c77eeb2a6d0120f92882b546762ae04df1 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 1 May 2024 21:45:47 +1000 Subject: [PATCH 01/10] Fixed deadlock with message listener --- cmd/dynamo-browse/main.go | 2 +- internal/common/ui/commandctrl/commandctrl.go | 15 ++++++++++----- internal/dynamo-browse/ui/model.go | 2 -- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index ac7ac1f..3a89f66 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -187,7 +187,7 @@ func main() { jobsController.SetMessageSender(p.Send) scriptController.Init() scriptController.SetMessageSender(p.Send) - commandController.SetMessageSender(p.Send) + go commandController.StartMessageSender(p.Send) log.Println("launching") if err := p.Start(); err != nil { diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index a1360d5..206f8a6 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -22,9 +22,9 @@ type CommandController struct { uclInst *ucl.Inst historyProvider IterProvider commandList *CommandList - msgSender func(tea.Msg) lookupExtensions []CommandLookupExtension completionProvider CommandCompletionProvider + msgChan chan tea.Msg } func NewCommandController(historyProvider IterProvider) *CommandController { @@ -32,6 +32,7 @@ func NewCommandController(historyProvider IterProvider) *CommandController { historyProvider: historyProvider, commandList: nil, lookupExtensions: nil, + msgChan: make(chan tea.Msg), } cc.uclInst = ucl.New( ucl.WithOut(ucl.LineHandler(cc.printLine)), @@ -45,8 +46,10 @@ func (c *CommandController) AddCommands(ctx *CommandList) { c.commandList = ctx } -func (c *CommandController) SetMessageSender(msg func(tea.Msg)) { - c.msgSender = msg +func (c *CommandController) StartMessageSender(msgSender func(tea.Msg)) { + for msg := range c.msgChan { + msgSender(msg) + } } func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) { @@ -183,8 +186,10 @@ func (c *CommandController) cmdInvoker(ctx context.Context, name string, args uc } func (c *CommandController) printLine(s string) { - if c.msgSender != nil { - c.msgSender(events.StatusMsg(s)) + select { + case c.msgChan <- events.StatusMsg(s): + default: + log.Println(s) } } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 8d3297e..ac6d873 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -170,8 +170,6 @@ func NewModel( itemType = models.NullItemType case args.HasSwitch("TO"): itemType = models.ExprValueItemType - default: - return events.Error(errors.New("unrecognised item type")) } return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, fieldName) From 94b58e2168be49bbec4f6dd015099b0ae3f0d046 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 4 May 2024 11:40:24 +1000 Subject: [PATCH 02/10] Updated UCL and added an interactive mode --- go.mod | 2 +- go.sum | 6 +++ internal/common/ui/commandctrl/commandctrl.go | 45 ++++++++++--------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index bf5197e..2eb4b60 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-20240501110514-25594c80d273 // indirect + ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281 // indirect ) diff --git a/go.sum b/go.sum index 526e0ec..2286e36 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwS github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879 h1:M5ptEKnqKqpFTKbe+p5zEf3ro1deJ6opUz5j3g3/ErQ= github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= github.com/alecthomas/assert/v2 v2.0.3 h1:WKqJODfOiQG0nEJKFKzDIG3E29CN2/4zR9XGJzKIkbg= +github.com/alecthomas/participle v0.7.1 h1:2bN7reTw//5f0cugJcTOnY/NYZcWQOaajW+BwZB5xWs= +github.com/alecthomas/participle v0.7.1/go.mod h1:HfdmEuwvr12HXQN44HPWXR0lHmVolVYe4dyL6lQ3duY= github.com/alecthomas/participle/v2 v2.0.0-beta.5 h1:y6dsSYVb1G5eK6mgmy+BgI3Mw35a3WghArZ/Hbebrjo= github.com/alecthomas/participle/v2 v2.0.0-beta.5/go.mod h1:RC764t6n4L8D8ITAJv0qdokritYSNR3wV5cVwmIEaMM= github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8= @@ -430,3 +432,7 @@ ucl.lmika.dev v0.0.0-20240427010304-6315afc54287 h1:llPHrjca54duvQx9PgMTFDhOW2VQ ucl.lmika.dev v0.0.0-20240427010304-6315afc54287/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= ucl.lmika.dev v0.0.0-20240501110514-25594c80d273 h1:+JpKw02VTAcOjJw7Q6juun/9hk9ypNSdTRlf+E4M5Nw= ucl.lmika.dev v0.0.0-20240501110514-25594c80d273/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= +ucl.lmika.dev v0.0.0-20240504001444-cf3a12bf0d4d h1:OqGmR0Y+OG6aFIOlXy2QwEHtuUNasYCh/6cxHokYQj4= +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= diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 206f8a6..988021e 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -1,8 +1,6 @@ package commandctrl import ( - "bufio" - "bytes" "context" tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" @@ -11,6 +9,7 @@ import ( "path/filepath" "strings" "ucl.lmika.dev/ucl" + "ucl.lmika.dev/ucl/builtins" "github.com/lmika/dynamo-browse/internal/common/ui/events" "github.com/lmika/shellwords" @@ -25,6 +24,7 @@ type CommandController struct { lookupExtensions []CommandLookupExtension completionProvider CommandCompletionProvider msgChan chan tea.Msg + interactive bool } func NewCommandController(historyProvider IterProvider) *CommandController { @@ -33,10 +33,13 @@ func NewCommandController(historyProvider IterProvider) *CommandController { commandList: nil, lookupExtensions: nil, 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)), ) return cc } @@ -136,6 +139,12 @@ func (c *CommandController) lookupCommand(name string) Command { } func (c *CommandController) ExecuteFile(filename string) error { + oldInteractive := c.interactive + c.interactive = false + defer func() { + c.interactive = oldInteractive + }() + baseFilename := filepath.Base(filename) if rcFile, err := os.ReadFile(filename); err == nil { @@ -149,27 +158,14 @@ func (c *CommandController) ExecuteFile(filename string) error { } func (c *CommandController) executeFile(file []byte, filename string) error { - scnr := bufio.NewScanner(bytes.NewReader(file)) - - lineNo := 0 - for scnr.Scan() { - lineNo++ - line := strings.TrimSpace(scnr.Text()) - if line == "" { - continue - } else if line[0] == '#' { - continue - } - - msg := c.execute(ExecContext{FromFile: true}, line) - switch m := msg.(type) { - case events.ErrorMsg: - log.Printf("%v:%v: error - %v", filename, lineNo, m.Error()) - case events.StatusMsg: - log.Printf("%v:%v: %v", filename, lineNo, 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 scnr.Err() + return nil } func (c *CommandController) cmdInvoker(ctx context.Context, name string, args ucl.CallArgs) (any, error) { @@ -186,6 +182,11 @@ func (c *CommandController) cmdInvoker(ctx context.Context, name string, args uc } func (c *CommandController) printLine(s string) { + if c.msgChan == nil || !c.interactive { + log.Println(s) + return + } + select { case c.msgChan <- events.StatusMsg(s): default: From 17381f3d0b841987e2db355d638690b29f52f9e9 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 15 May 2025 22:16:02 +1000 Subject: [PATCH 03/10] Started re-engineering the UCL command instance --- .gitignore | 2 + cmd/dynamo-browse/main.go | 7 +- go.mod | 2 +- go.sum | 2 + .../common/ui/commandctrl/cmdpacks/stdcmds.go | 60 ++++ internal/common/ui/commandctrl/commandctrl.go | 76 ++++- internal/common/ui/commandctrl/ctx.go | 17 + internal/common/ui/commandctrl/packs.go | 7 + internal/dynamo-browse/ui/model.go | 308 +++++++++--------- 9 files changed, 309 insertions(+), 172 deletions(-) create mode 100644 internal/common/ui/commandctrl/cmdpacks/stdcmds.go create mode 100644 internal/common/ui/commandctrl/ctx.go create mode 100644 internal/common/ui/commandctrl/packs.go 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) From cb908ec4eb8ee77fde6720d07ab839c851a395c2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Fri, 16 May 2025 18:01:28 +1000 Subject: [PATCH 04/10] Added back the core interactive commands --- cmd/dynamo-browse/main.go | 5 +- .../common/ui/commandctrl/cmdpacks/stdcmds.go | 324 +++++++++++++++++- internal/common/ui/commandctrl/commandctrl.go | 5 + internal/common/ui/commandctrl/ctx.go | 9 + internal/common/ui/commandctrl/iface.go | 4 + internal/dynamo-browse/ui/model.go | 4 + 6 files changed, 347 insertions(+), 4 deletions(-) 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() +} From 6bf721873b6bfe78ddaf41556ab83cc5345bfb8d Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 17 May 2025 11:11:04 +1000 Subject: [PATCH 05/10] Added rs:new and rs:query --- cmd/dynamo-browse/main.go | 9 +- go.mod | 6 +- go.sum | 4 + .../common/ui/commandctrl/cmdpacks/modrs.go | 123 +++++++++++++++ .../ui/commandctrl/cmdpacks/modrs_test.go | 80 ++++++++++ .../common/ui/commandctrl/cmdpacks/proxy.go | 7 + .../common/ui/commandctrl/cmdpacks/stdcmds.go | 9 ++ .../ui/commandctrl/cmdpacks/stdcmds_test.go | 143 ++++++++++++++++++ internal/common/ui/commandctrl/commandctrl.go | 26 ++-- internal/common/ui/commandctrl/packs.go | 1 + .../dynamo-browse/models/queryexpr/types.go | 35 +++++ 11 files changed, 424 insertions(+), 19 deletions(-) create mode 100644 internal/common/ui/commandctrl/cmdpacks/modrs.go create mode 100644 internal/common/ui/commandctrl/cmdpacks/modrs_test.go create mode 100644 internal/common/ui/commandctrl/cmdpacks/proxy.go create mode 100644 internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 820abc0..aeaf4eb 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -162,9 +162,12 @@ func main() { commandController := commandctrl.NewCommandController(inputHistoryService, cmdpacks.StandardCommands{ - ReadController: tableReadController, - WriteController: tableWriteController, - ExportController: exportController, + TableService: tableService, + State: state, + ReadController: tableReadController, + WriteController: tableWriteController, + ExportController: exportController, + KeyBindingController: keyBindingController, }, ) commandController.AddCommandLookupExtension(scriptController) diff --git a/go.mod b/go.mod index 4f87cec..3678e7c 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/lmika/dynamo-browse -go 1.22 +go 1.24 -toolchain go1.22.0 +toolchain go1.24.0 require ( github.com/alecthomas/participle/v2 v2.1.1 @@ -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-20250306030053-ad6d002a22e8 // indirect + ucl.lmika.dev v0.0.0-20250517003439-109be33d1495 // indirect ) diff --git a/go.sum b/go.sum index f26fcfa..fdaef5d 100644 --- a/go.sum +++ b/go.sum @@ -438,3 +438,7 @@ ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281 h1:/M7phiv/0XVp3wKkOxEnGQysf8+R 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= +ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2 h1:cvguOoQ0HVgLKbHH17ZHvAUFht6HXApLi0o8JOdaaNU= +ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517003439-109be33d1495 h1:r46r+7T59Drm+in7TEWKCZfFYIM0ZyZ26QjHAbj8Lto= +ucl.lmika.dev v0.0.0-20250517003439-109be33d1495/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go new file mode 100644 index 0000000..a748899 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -0,0 +1,123 @@ +package cmdpacks + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "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/models/queryexpr" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" + "github.com/pkg/errors" + "ucl.lmika.dev/repl" + "ucl.lmika.dev/ucl" +) + +type rsModule struct { + tableService *tables.Service + state *controllers.State +} + +var rsNewDoc = repl.Doc{ + Brief: "Creates a new, empty result set", +} + +func (rs *rsModule) rsNew(ctx context.Context, args ucl.CallArgs) (any, error) { + return &ResultSetProxy{ + RS: &models.ResultSet{}, + }, nil +} + +var rsQueryDoc = repl.Doc{ + Brief: "Runs a query and returns the results as a result-set", + Usage: "QUERY [ARGS] [-table NAME]", + Args: []repl.ArgDoc{ + {Name: "query", Brief: "Query expression to run"}, + {Name: "args", Brief: "Hash of argument values to substitute into the query"}, + {Name: "-table", Brief: "Optional table name to use for the query"}, + }, + Detailed: ` + If no table is specified, then the value of @table will be used. If this is unavailable, + the command will return an error. + `, +} + +func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) { + var expr string + if err := args.Bind(&expr); err != nil { + return nil, err + } + + q, err := queryexpr.Parse(expr) + if err != nil { + return nil, err + } + + if args.NArgs() > 0 { + var queryArgs ucl.Hashable + if err := args.Bind(&queryArgs); err != nil { + return nil, err + } + + queryNames := map[string]string{} + queryValues := map[string]types.AttributeValue{} + queryArgs.Each(func(k string, v ucl.Object) error { + if v == nil { + return nil + } + + queryNames[k] = v.String() + + switch v.(type) { + case ucl.StringObject: + queryValues[k] = &types.AttributeValueMemberS{Value: v.String()} + case ucl.IntObject: + queryValues[k] = &types.AttributeValueMemberN{Value: v.String()} + // TODO: other types + } + return nil + }) + + q = q.WithNameParams(queryNames).WithValueParams(queryValues) + } + + var tableInfo *models.TableInfo + if args.HasSwitch("table") { + var tblName string + if err := args.BindSwitch("table", &tblName); err != nil { + return nil, err + } + + tableInfo, err = rs.tableService.Describe(ctx, tblName) + if err != nil { + return nil, err + } + } else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil { + tableInfo = currRs.TableInfo + } else { + return nil, errors.New("no table specified") + } + + newResultSet, err := rs.tableService.ScanOrQuery(context.Background(), tableInfo, q, nil) + if err != nil { + return nil, err + } + + return &ResultSetProxy{ + RS: newResultSet, + }, nil +} + +func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module { + m := &rsModule{ + tableService: tableService, + state: state, + } + + return ucl.Module{ + Name: "rs", + Builtins: map[string]ucl.BuiltinHandler{ + "new": m.rsNew, + "query": m.rsQuery, + }, + } +} diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go new file mode 100644 index 0000000..113bd46 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go @@ -0,0 +1,80 @@ +package cmdpacks_test + +import ( + "fmt" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestModRS_New(t *testing.T) { + svc := newService(t) + + rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:new`) + + assert.NoError(t, err) + assert.IsType(t, rsProxy, &cmdpacks.ResultSetProxy{}) +} + +func TestModRS_Query(t *testing.T) { + tests := []struct { + descr string + cmd string + wantRows []int + }{ + { + descr: "query with pk 1", + cmd: `rs:query 'pk="abc"' -table service-test-data`, + wantRows: []int{0, 1}, + }, + { + descr: "query with pk 2", + cmd: `rs:query 'pk="bbb"' -table service-test-data`, + wantRows: []int{2}, + }, + { + descr: "query with sk 1", + cmd: `rs:query 'sk="222"' -table service-test-data`, + wantRows: []int{1}, + }, + { + descr: "query with args 1", + cmd: `rs:query 'pk=$v' [v:'abc'] -table service-test-data`, + wantRows: []int{0, 1}, + }, + { + descr: "query with args 2", + cmd: `rs:query ':k=$v' [k:'pk' v:'abc'] -table service-test-data`, + wantRows: []int{0, 1}, + }, + { + descr: "query with args 3", + cmd: `rs:query ':k=$v' [k:'beta' v:1231] -table service-test-data`, + wantRows: []int{1}, + }, + { + descr: "query with args with no table set", + cmd: `rs:query ':k=$v' [k:'beta' v:1231]`, + wantRows: []int{1}, + }, + } + for _, tt := range tests { + t.Run(tt.descr, func(t *testing.T) { + svc := newService(t) + + res, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd) + assert.NoError(t, err) + + rs := res.(*cmdpacks.ResultSetProxy).RS + assert.Len(t, rs.Items(), len(tt.wantRows)) + + for i, rowIndex := range tt.wantRows { + for key, want := range testData[0].Data[rowIndex] { + have, ok := rs.Items()[i].AttributeValueAsString(key) + assert.True(t, ok) + assert.Equal(t, fmt.Sprint(want), have) + } + } + }) + } +} diff --git a/internal/common/ui/commandctrl/cmdpacks/proxy.go b/internal/common/ui/commandctrl/cmdpacks/proxy.go new file mode 100644 index 0000000..b80aab2 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/proxy.go @@ -0,0 +1,7 @@ +package cmdpacks + +import "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + +type ResultSetProxy struct { + RS *models.ResultSet +} diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index 63cbcfa..21756a3 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -6,12 +6,15 @@ import ( "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/lmika/dynamo-browse/internal/dynamo-browse/services/tables" "github.com/pkg/errors" "ucl.lmika.dev/repl" "ucl.lmika.dev/ucl" ) type StandardCommands struct { + TableService *tables.Service + State *controllers.State ReadController *controllers.TableReadController WriteController *controllers.TableWriteController ExportController *controllers.ExportController @@ -358,6 +361,12 @@ func (sc StandardCommands) cmdRebind(ctx context.Context, args ucl.CallArgs) (an return nil, nil } +func (sc StandardCommands) InstOptions() []ucl.InstOption { + return []ucl.InstOption{ + ucl.WithModule(moduleRS(sc.TableService, sc.State)), + } +} + func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { ucl.SetBuiltin("quit", sc.cmdQuit) ucl.SetBuiltin("table", sc.cmdTable) diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go new file mode 100644 index 0000000..48dd158 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go @@ -0,0 +1,143 @@ +package cmdpacks_test + +import ( + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/inputhistorystore" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/settingstore" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/workspacestore" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/inputhistory" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot" + "github.com/lmika/dynamo-browse/test/testdynamo" + "github.com/lmika/dynamo-browse/test/testworkspace" + bus "github.com/lmika/events" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestStdCmds_Mark(t *testing.T) { + tests := []struct { + descr string + cmd string + wantMarks []bool + }{ + {descr: "mark default", cmd: "mark", wantMarks: []bool{true, true, true}}, + {descr: "mark all", cmd: "mark all", wantMarks: []bool{true, true, true}}, + {descr: "mark none", cmd: "mark none", wantMarks: []bool{false, false, false}}, + {descr: "mark where", cmd: `mark -where 'sk="222"'`, wantMarks: []bool{false, true, false}}, + {descr: "mark toggle", cmd: "mark ; mark toggle", wantMarks: []bool{false, false, false}}, + } + + for _, tt := range tests { + t.Run(tt.descr, func(t *testing.T) { + svc := newService(t) + + _, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd) + assert.NoError(t, err) + + for i, want := range tt.wantMarks { + assert.Equal(t, want, svc.State.ResultSet().Marked(i)) + } + }) + } +} + +type services struct { + CommandController *commandctrl.CommandController + SelItemIndex int + + State *controllers.State +} + +func newService(t *testing.T) *services { + ws := testworkspace.New(t) + + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws) + settingStore := settingstore.New(ws) + inputHistoryStore := inputhistorystore.NewInputHistoryStore(ws) + + workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) + itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer()) + inputHistoryService := inputhistory.New(inputHistoryStore) + + client := testdynamo.SetupTestTable(t, testData) + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider, settingStore) + eventBus := bus.New() + + state := controllers.NewState() + jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true) + readController := controllers.NewTableReadController( + state, + service, + workspaceService, + itemRendererService, + jobsController, + inputHistoryService, + eventBus, + pasteboardprovider.NilProvider{}, + nil, + "service-test-data", + ) + writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) + settingsController := controllers.NewSettingsController(settingStore, eventBus) + columnsController := controllers.NewColumnsController(readController, eventBus) + exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{}) + + _ = settingsController + commandController := commandctrl.NewCommandController(inputHistoryService, + cmdpacks.StandardCommands{ + State: state, + TableService: service, + ReadController: readController, + WriteController: writeController, + ExportController: exportController, + }, + ) + + s := &services{ + State: state, + CommandController: commandController, + } + + commandController.SetUIStateProvider(s) + readController.Init() + + return s +} + +func (s *services) SelectedItemIndex() int { + return s.SelItemIndex +} + +var testData = []testdynamo.TestData{ + { + TableName: "service-test-data", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, + }, +} diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 99bec81..79847f0 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -43,11 +43,17 @@ func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) *Co msgChan: make(chan tea.Msg), interactive: true, } - cc.uclInst = ucl.New( + + options := []ucl.InstOption{ ucl.WithOut(ucl.LineHandler(cc.printLine)), ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.FS(nil)), - ) + } + for _, pkg := range pkgs { + options = append(options, pkg.InstOptions()...) + } + + cc.uclInst = ucl.New(options...) for _, pkg := range pkgs { pkg.ConfigureUCL(cc.uclInst) @@ -126,26 +132,20 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms return events.Error(errors.New("command currently running")) } - /* - 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) ExecuteAndWait(ctx context.Context, commandInput string) (any, error) { + return c.uclInst.Eval(ctx, commandInput) +} + 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) + res, err := c.ExecuteAndWait(ctx, cmdChan.cmd) if err != nil { c.postMessage(events.Error(err)) } else if res != nil { diff --git a/internal/common/ui/commandctrl/packs.go b/internal/common/ui/commandctrl/packs.go index 76d0bd7..6f5dbc6 100644 --- a/internal/common/ui/commandctrl/packs.go +++ b/internal/common/ui/commandctrl/packs.go @@ -3,5 +3,6 @@ package commandctrl import "ucl.lmika.dev/ucl" type CommandPack interface { + InstOptions() []ucl.InstOption ConfigureUCL(ucl *ucl.Inst) } diff --git a/internal/dynamo-browse/models/queryexpr/types.go b/internal/dynamo-browse/models/queryexpr/types.go index 2e55c7a..011931e 100644 --- a/internal/dynamo-browse/models/queryexpr/types.go +++ b/internal/dynamo-browse/models/queryexpr/types.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" "math/big" "strconv" + "strings" ) type exprValue interface { @@ -62,6 +63,14 @@ func newExprValueFromAttributeValue(ev types.AttributeValue) (exprValue, error) case *types.AttributeValueMemberS: return stringExprValue(xVal.Value), nil case *types.AttributeValueMemberN: + if !strings.Contains(xVal.Value, ".") { + iVal, err := strconv.ParseInt(xVal.Value, 10, 64) + if err != nil { + return nil, err + } + return int64ExprValue(iVal), nil + } + xNumVal, _, err := big.ParseFloat(xVal.Value, 10, 63, big.ToNearestEven) if err != nil { return nil, err @@ -139,6 +148,32 @@ func (s int64ExprValue) typeName() string { return "N" } +type bigIntExprValue struct { + num *big.Int +} + +func (i bigIntExprValue) asGoValue() any { + return i.num +} + +func (i bigIntExprValue) asAttributeValue() types.AttributeValue { + return &types.AttributeValueMemberN{Value: i.num.String()} +} + +func (i bigIntExprValue) asInt() int64 { + return i.num.Int64() +} + +func (i bigIntExprValue) asBigFloat() *big.Float { + var f big.Float + f.SetInt64(i.num.Int64()) + return &f +} + +func (s bigIntExprValue) typeName() string { + return "N" +} + type bigNumExprValue struct { num *big.Float } From 18ffe85a562bc77e4f29bdc75812f316d1c68d01 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 17 May 2025 22:16:49 +1000 Subject: [PATCH 06/10] First attempt at a resultset pseudovar The resultset needs a table set, so rs:new will also assume the current table. --- go.mod | 2 +- go.sum | 2 ++ .../common/ui/commandctrl/cmdpacks/modrs.go | 34 ++++++++++++++++--- .../common/ui/commandctrl/cmdpacks/pvars.go | 31 +++++++++++++++++ .../common/ui/commandctrl/cmdpacks/stdcmds.go | 2 ++ internal/dynamo-browse/controllers/state.go | 8 +++++ .../dynamo-browse/controllers/tableread.go | 6 ++++ test/testdynamo/client.go | 8 ++++- 8 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 internal/common/ui/commandctrl/cmdpacks/pvars.go diff --git a/go.mod b/go.mod index 3678e7c..51c142d 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-20250517003439-109be33d1495 // indirect + ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e // indirect ) diff --git a/go.sum b/go.sum index fdaef5d..1e50447 100644 --- a/go.sum +++ b/go.sum @@ -442,3 +442,5 @@ ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2 h1:cvguOoQ0HVgLKbHH17ZHvAUFht6H ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= ucl.lmika.dev v0.0.0-20250517003439-109be33d1495 h1:r46r+7T59Drm+in7TEWKCZfFYIM0ZyZ26QjHAbj8Lto= ucl.lmika.dev v0.0.0-20250517003439-109be33d1495/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e h1:CQ+qPqI5lYiiEM0tNAr4jS0iMz16bFqOui5mU3AHsCU= +ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go index a748899..4af1513 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -8,6 +8,7 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" "github.com/pkg/errors" + "time" "ucl.lmika.dev/repl" "ucl.lmika.dev/ucl" ) @@ -19,11 +20,36 @@ type rsModule struct { var rsNewDoc = repl.Doc{ Brief: "Creates a new, empty result set", + Usage: "[-table NAME]", + Detailed: ` + The result set assumes the details of the current table. If no table is specified, + the command will return an error. + `, } -func (rs *rsModule) rsNew(ctx context.Context, args ucl.CallArgs) (any, error) { - return &ResultSetProxy{ - RS: &models.ResultSet{}, +func (rs *rsModule) rsNew(ctx context.Context, args ucl.CallArgs) (_ any, err error) { + var tableInfo *models.TableInfo + if args.HasSwitch("table") { + var tblName string + if err := args.BindSwitch("table", &tblName); err != nil { + return nil, err + } + + tableInfo, err = rs.tableService.Describe(ctx, tblName) + if err != nil { + return nil, err + } + } else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil { + tableInfo = currRs.TableInfo + } else { + return nil, errors.New("no table specified") + } + + return ResultSetProxy{ + RS: &models.ResultSet{ + TableInfo: tableInfo, + Created: time.Now(), + }, }, nil } @@ -102,7 +128,7 @@ func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) return nil, err } - return &ResultSetProxy{ + return ResultSetProxy{ RS: newResultSet, }, nil } diff --git a/internal/common/ui/commandctrl/cmdpacks/pvars.go b/internal/common/ui/commandctrl/cmdpacks/pvars.go new file mode 100644 index 0000000..5f5020e --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/pvars.go @@ -0,0 +1,31 @@ +package cmdpacks + +import ( + "context" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" + "github.com/pkg/errors" + "log" +) + +type resultSetPVar struct { + state *controllers.State + readController *controllers.TableReadController +} + +func (rs resultSetPVar) Get(ctx context.Context) (any, error) { + return ResultSetProxy{rs.state.ResultSet()}, nil +} + +func (rs resultSetPVar) Set(ctx context.Context, value any) error { + rsVal, ok := value.(ResultSetProxy) + if !ok { + return errors.New("new value to @resultset is not a result set") + } + + log.Printf("type = %T", rsVal.RS) + + msg := rs.readController.SetResultSet(rsVal.RS) + commandctrl.PostMsg(ctx, msg) + return nil +} diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index 21756a3..4682926 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -384,4 +384,6 @@ func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { ucl.SetBuiltin("noisy-touch", sc.cmdNoisyTouch) ucl.SetBuiltin("rebind", sc.cmdRebind) // set-opt --> alias to opts:set + + ucl.SetPseudoVar("resultset", resultSetPVar{sc.State, sc.ReadController}) } diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go index 6a886d2..8f7ea82 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -29,6 +29,14 @@ func (s *State) Filter() string { return s.filter } +func (s *State) SetResultSet(resultSet *models.ResultSet) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.resultSet = resultSet + s.filter = "" +} + func (s *State) withResultSet(rs func(*models.ResultSet)) { s.mutex.Lock() defer s.mutex.Unlock() diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 6ef6e18..e7034a5 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -291,6 +291,12 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, return c.state.buildNewResultSetMessage("") } +func (c *TableReadController) SetResultSet(resultSet *models.ResultSet) tea.Msg { + c.state.setResultSetAndFilter(resultSet, "") + c.eventBus.Fire(newResultSetEvent, resultSet, resultSetUpdateScript) + return c.state.buildNewResultSetMessage("") +} + func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg { var ( whereExpr *queryexpr.QueryExpr diff --git a/test/testdynamo/client.go b/test/testdynamo/client.go index 68c9065..688ea25 100644 --- a/test/testdynamo/client.go +++ b/test/testdynamo/client.go @@ -2,6 +2,7 @@ package testdynamo import ( "context" + "os" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -28,8 +29,13 @@ func SetupTestTable(t *testing.T, testData []TestData) *dynamodb.Client { config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("abc", "123", ""))) assert.NoError(t, err) + testDynamoURL, ok := os.LookupEnv("TEST_DYNAMO_URL") + if !ok { + testDynamoURL = "http://localhost:4566" + } + dynamoClient := dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:4566"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL(testDynamoURL))) for _, table := range testData { tableInput := &dynamodb.CreateTableInput{ From 40f8dd76e230ce2b644dffd13074aba117177e67 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 18 May 2025 13:42:44 +1000 Subject: [PATCH 07/10] ucl: Have started adding some of the psudo variables --- go.mod | 2 +- go.sum | 10 + .../common/ui/commandctrl/cmdpacks/modrs.go | 14 +- .../common/ui/commandctrl/cmdpacks/proxy.go | 179 +++++++++++++++++- .../common/ui/commandctrl/cmdpacks/pvars.go | 43 ++++- .../common/ui/commandctrl/cmdpacks/stdcmds.go | 2 + internal/common/ui/commandctrl/ctx.go | 9 + internal/common/ui/commandctrl/iface.go | 2 + internal/dynamo-browse/ui/model.go | 4 + .../ui/teamodels/dynamotableview/model.go | 21 ++ 10 files changed, 267 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 51c142d..887aaf1 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-20250517115116-0f1ceba0902e // indirect + ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78 // indirect ) diff --git a/go.sum b/go.sum index 1e50447..d77e7b4 100644 --- a/go.sum +++ b/go.sum @@ -444,3 +444,13 @@ ucl.lmika.dev v0.0.0-20250517003439-109be33d1495 h1:r46r+7T59Drm+in7TEWKCZfFYIM0 ucl.lmika.dev v0.0.0-20250517003439-109be33d1495/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e h1:CQ+qPqI5lYiiEM0tNAr4jS0iMz16bFqOui5mU3AHsCU= ucl.lmika.dev v0.0.0-20250517115116-0f1ceba0902e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517212052-51e35aa9a675 h1:kGKh3zj6lMzOrGAquFW7ROgx9/6nwJ8DXiSLtceRiak= +ucl.lmika.dev v0.0.0-20250517212052-51e35aa9a675/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517212757-33d04ba18db4 h1:rnietWu2B+NXLqKfo7jgf6r+srMwxFa5eizywkq4LFk= +ucl.lmika.dev v0.0.0-20250517212757-33d04ba18db4/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517213937-94aad417121d h1:CMcA8aQV6iiPK75EbHvoIVZhZmSggfrWNhK9BFm2aIg= +ucl.lmika.dev v0.0.0-20250517213937-94aad417121d/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94 h1:x3IRtT1jbedblimi2hesKGBihg243+wNOSvagCPR0KU= +ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78 h1:lbOZUb6whYMLI4win5QL+eLSgqc3N9TtTgT8hTipNl8= +ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go index 4af1513..5f3060b 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -45,12 +45,10 @@ func (rs *rsModule) rsNew(ctx context.Context, args ucl.CallArgs) (_ any, err er return nil, errors.New("no table specified") } - return ResultSetProxy{ - RS: &models.ResultSet{ - TableInfo: tableInfo, - Created: time.Now(), - }, - }, nil + return newResultSetProxy(&models.ResultSet{ + TableInfo: tableInfo, + Created: time.Now(), + }), nil } var rsQueryDoc = repl.Doc{ @@ -128,9 +126,7 @@ func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) return nil, err } - return ResultSetProxy{ - RS: newResultSet, - }, nil + return newResultSetProxy(newResultSet), nil } func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module { diff --git a/internal/common/ui/commandctrl/cmdpacks/proxy.go b/internal/common/ui/commandctrl/cmdpacks/proxy.go index b80aab2..5606f94 100644 --- a/internal/common/ui/commandctrl/cmdpacks/proxy.go +++ b/internal/common/ui/commandctrl/cmdpacks/proxy.go @@ -1,7 +1,180 @@ package cmdpacks -import "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" +import ( + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "maps" + "strconv" + "ucl.lmika.dev/ucl" +) -type ResultSetProxy struct { - RS *models.ResultSet +type proxyFields[T any] map[string]func(t T) ucl.Object + +type simpleProxy[T comparable] struct { + value T + fields proxyFields[T] +} + +func (tp simpleProxy[T]) String() string { + return fmt.Sprint(tp.value) +} + +func (tp simpleProxy[T]) Truthy() bool { + var zeroT T + return tp.value != zeroT +} + +func (tp simpleProxy[T]) Len() int { + return len(tp.fields) +} + +func (tp simpleProxy[T]) Value(k string) ucl.Object { + f, ok := tp.fields[k] + if !ok { + return nil + } + return f(tp.value) +} + +func (tp simpleProxy[T]) Each(fn func(k string, v ucl.Object) error) error { + for key := range maps.Keys(tp.fields) { + if err := fn(key, tp.Value(key)); err != nil { + return err + } + } + return nil +} + +type simpleProxyList[T comparable] struct { + values []T + converter func(T) ucl.Object +} + +func newSimpleProxyList[T comparable](values []T, converter func(T) ucl.Object) simpleProxyList[T] { + return simpleProxyList[T]{values: values, converter: converter} +} + +func (tp simpleProxyList[T]) String() string { + return fmt.Sprint(tp.values) +} + +func (tp simpleProxyList[T]) Truthy() bool { + return len(tp.values) > 0 +} + +func (tp simpleProxyList[T]) Len() int { + return len(tp.values) +} + +func (tp simpleProxyList[T]) Index(k int) ucl.Object { + return tp.converter(tp.values[k]) +} + +func newResultSetProxy(rs *models.ResultSet) ucl.Object { + return simpleProxy[*models.ResultSet]{value: rs, fields: resultSetProxyFields} +} + +var resultSetProxyFields = proxyFields[*models.ResultSet]{ + "Table": func(t *models.ResultSet) ucl.Object { return newTableProxy(t.TableInfo) }, + "Items": func(t *models.ResultSet) ucl.Object { return resultSetItemsProxy{t} }, +} + +func newTableProxy(table *models.TableInfo) ucl.Object { + return simpleProxy[*models.TableInfo]{value: table, fields: tableProxyFields} +} + +var tableProxyFields = proxyFields[*models.TableInfo]{ + "Name": func(t *models.TableInfo) ucl.Object { return ucl.StringObject(t.Name) }, + "Keys": func(t *models.TableInfo) ucl.Object { return newKeyAttributeProxy(t.Keys) }, + "DefinedAttributes": func(t *models.TableInfo) ucl.Object { return ucl.StringListObject(t.DefinedAttributes) }, + "GSIs": func(t *models.TableInfo) ucl.Object { return newSimpleProxyList(t.GSIs, newGSIProxy) }, +} + +func newKeyAttributeProxy(keyAttrs models.KeyAttribute) ucl.Object { + return simpleProxy[models.KeyAttribute]{value: keyAttrs, fields: keyAttributeProxyFields} +} + +var keyAttributeProxyFields = proxyFields[models.KeyAttribute]{ + "PartitionKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) }, + "SortKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) }, +} + +func newGSIProxy(gsi models.TableGSI) ucl.Object { + return simpleProxy[models.TableGSI]{value: gsi, fields: gsiProxyFields} +} + +var gsiProxyFields = proxyFields[models.TableGSI]{ + "Name": func(t models.TableGSI) ucl.Object { return ucl.StringObject(t.Name) }, + "Keys": func(t models.TableGSI) ucl.Object { return newKeyAttributeProxy(t.Keys) }, +} + +type resultSetItemsProxy struct { + resultSet *models.ResultSet +} + +func (ip resultSetItemsProxy) String() string { + return "items" +} + +func (ip resultSetItemsProxy) Truthy() bool { + return len(ip.resultSet.Items()) > 0 +} + +func (tp resultSetItemsProxy) Len() int { + return len(tp.resultSet.Items()) +} + +func (tp resultSetItemsProxy) Index(k int) ucl.Object { + return itemProxy{resultSet: tp.resultSet, idx: k, item: tp.resultSet.Items()[k]} +} + +type itemProxy struct { + resultSet *models.ResultSet + idx int + item models.Item +} + +func (ip itemProxy) String() string { + return "item" +} + +func (ip itemProxy) Truthy() bool { + return len(ip.item) > 0 +} + +func (tp itemProxy) Len() int { + return len(tp.item) +} + +func (tp itemProxy) Value(k string) ucl.Object { + f, ok := tp.item[k] + if !ok { + return nil + } + return convertAttributeValueToUCLObject(f) +} + +func (tp itemProxy) Each(fn func(k string, v ucl.Object) error) error { + for key := range maps.Keys(tp.item) { + if err := fn(key, tp.Value(key)); err != nil { + return err + } + } + return nil +} + +func convertAttributeValueToUCLObject(attrValue types.AttributeValue) ucl.Object { + switch t := attrValue.(type) { + case *types.AttributeValueMemberS: + return ucl.StringObject(t.Value) + case *types.AttributeValueMemberN: + i, err := strconv.ParseInt(t.Value, 10, 64) + if err != nil { + return nil + } + return ucl.IntObject(i) + } + // TODO: the rest + return nil } diff --git a/internal/common/ui/commandctrl/cmdpacks/pvars.go b/internal/common/ui/commandctrl/cmdpacks/pvars.go index 5f5020e..d297a98 100644 --- a/internal/common/ui/commandctrl/cmdpacks/pvars.go +++ b/internal/common/ui/commandctrl/cmdpacks/pvars.go @@ -4,28 +4,59 @@ import ( "context" "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" - "log" ) +type tablePVar struct { + state *controllers.State +} + +func (rs tablePVar) Get(ctx context.Context) (any, error) { + return newTableProxy(rs.state.ResultSet().TableInfo), nil +} + type resultSetPVar struct { state *controllers.State readController *controllers.TableReadController } func (rs resultSetPVar) Get(ctx context.Context) (any, error) { - return ResultSetProxy{rs.state.ResultSet()}, nil + return newResultSetProxy(rs.state.ResultSet()), nil } func (rs resultSetPVar) Set(ctx context.Context, value any) error { - rsVal, ok := value.(ResultSetProxy) + rsVal, ok := value.(simpleProxy[*models.ResultSet]) if !ok { return errors.New("new value to @resultset is not a result set") } - log.Printf("type = %T", rsVal.RS) - - msg := rs.readController.SetResultSet(rsVal.RS) + msg := rs.readController.SetResultSet(rsVal.value) commandctrl.PostMsg(ctx, msg) return nil } + +type itemPVar struct { + state *controllers.State +} + +func (rs itemPVar) Get(ctx context.Context) (any, error) { + selItem, ok := commandctrl.SelectedItemIndex(ctx) + if !ok { + return nil, errors.New("no item selected") + } + return itemProxy{rs.state.ResultSet(), selItem, rs.state.ResultSet().Items()[selItem]}, nil +} + +func (rs itemPVar) Set(ctx context.Context, value any) error { + rsVal, ok := value.(itemProxy) + if !ok { + return errors.New("new value to @item is not an item") + } + + if msg := commandctrl.SetSelectedItemIndex(ctx, rsVal.idx); msg != nil { + commandctrl.PostMsg(ctx, msg) + } + + return nil +} diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index 4682926..e5c1aab 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -386,4 +386,6 @@ func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { // set-opt --> alias to opts:set ucl.SetPseudoVar("resultset", resultSetPVar{sc.State, sc.ReadController}) + ucl.SetPseudoVar("table", tablePVar{sc.State}) + ucl.SetPseudoVar("item", itemPVar{sc.State}) } diff --git a/internal/common/ui/commandctrl/ctx.go b/internal/common/ui/commandctrl/ctx.go index 7c6f193..7fc70ea 100644 --- a/internal/common/ui/commandctrl/ctx.go +++ b/internal/common/ui/commandctrl/ctx.go @@ -24,3 +24,12 @@ func SelectedItemIndex(ctx context.Context) (int, bool) { return cmdCtl.uiStateProvider.SelectedItemIndex(), true } + +func SetSelectedItemIndex(ctx context.Context, newIdx int) tea.Msg { + cmdCtl, ok := ctx.Value(commandCtlKey).(*CommandController) + if !ok { + return nil + } + + return cmdCtl.uiStateProvider.SetSelectedItemIndex(newIdx) +} diff --git a/internal/common/ui/commandctrl/iface.go b/internal/common/ui/commandctrl/iface.go index 41d3a7f..e708d24 100644 --- a/internal/common/ui/commandctrl/iface.go +++ b/internal/common/ui/commandctrl/iface.go @@ -2,6 +2,7 @@ package commandctrl import ( "context" + tea "github.com/charmbracelet/bubbletea" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services" ) @@ -11,4 +12,5 @@ type IterProvider interface { type UIStateProvider interface { SelectedItemIndex() int + SetSelectedItemIndex(newIdx int) tea.Msg } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index d57c816..9acb70e 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -397,3 +397,7 @@ func (m *Model) promptToQuit() tea.Msg { func (m *Model) SelectedItemIndex() int { return m.tableView.SelectedItemIndex() } + +func (m *Model) SetSelectedItemIndex(newIdx int) tea.Msg { + return m.tableView.SetSelectedItemIndex(newIdx) +} diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index cf61d72..163de6f 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -208,6 +208,27 @@ func (m *Model) SelectedItemIndex() int { return selectedItem.itemIndex } +func (m *Model) SetSelectedItemIndex(newIdx int) tea.Msg { + cursor := m.table.Cursor() + switch { + case newIdx <= 0: + m.table.GoTop() + case newIdx >= len(m.rows)-1: + m.table.GoBottom() + case newIdx < cursor: + delta := cursor - newIdx + for d := 0; d < delta; d++ { + m.table.GoUp() + } + case newIdx > cursor: + delta := newIdx - cursor + for d := 0; d < delta; d++ { + m.table.GoDown() + } + } + return m.postSelectedItemChanged() +} + func (m *Model) selectedItem() (itemTableRow, bool) { resultSet := m.resultSet From 50880096725609bfb8d1ed79131cb24ca4410d90 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 19 May 2025 22:14:22 +1000 Subject: [PATCH 08/10] ucl: added more resultset functions --- go.mod | 2 +- go.sum | 6 + .../common/ui/commandctrl/cmdpacks/modrs.go | 85 +++++++++++- .../ui/commandctrl/cmdpacks/modrs_test.go | 80 ++++++++++- .../common/ui/commandctrl/cmdpacks/proxy.go | 102 +++++++++----- .../common/ui/commandctrl/cmdpacks/pvars.go | 4 +- .../ui/commandctrl/cmdpacks/stdcmds_test.go | 125 ++++++++++++++---- internal/dynamo-browse/models/models.go | 34 +++++ 8 files changed, 369 insertions(+), 69 deletions(-) diff --git a/go.mod b/go.mod index 887aaf1..ac57b4e 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-20250518033831-f79e91e26d78 // indirect + ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8 // indirect ) diff --git a/go.sum b/go.sum index d77e7b4..0aafeb1 100644 --- a/go.sum +++ b/go.sum @@ -454,3 +454,9 @@ ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94 h1:x3IRtT1jbedblimi2hesKGBihg24 ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78 h1:lbOZUb6whYMLI4win5QL+eLSgqc3N9TtTgT8hTipNl8= ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250519111943-1173d163f5e3 h1:ZMQ1rkcAWa///c3bVvlXbtuqjfAWxDm01abQl3g/YVw= +ucl.lmika.dev v0.0.0-20250519111943-1173d163f5e3/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a h1:dzBBFCY50+MQcJaQ90swdDyjzag5oIhwdfqbmZkvX3Q= +ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8 h1:h32JQi0d1MI86RaAMaEU7kvti4uSLX5XYe/nk2abApg= +ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go index 5f3060b..eea66b4 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -129,6 +129,84 @@ func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) return newResultSetProxy(newResultSet), nil } +var rsScanDoc = repl.Doc{ + Brief: "Performs a scan of the table and returns the results as a result-set", + Usage: "[-table NAME]", + Args: []repl.ArgDoc{ + {Name: "-table", Brief: "Optional table name to use for the query"}, + }, + Detailed: ` + If no table is specified, then the value of @table will be used. If this is unavailable, + the command will return an error. + `, +} + +func (rs *rsModule) rsScan(ctx context.Context, args ucl.CallArgs) (_ any, err error) { + var tableInfo *models.TableInfo + if args.HasSwitch("table") { + var tblName string + if err := args.BindSwitch("table", &tblName); err != nil { + return nil, err + } + + tableInfo, err = rs.tableService.Describe(ctx, tblName) + if err != nil { + return nil, err + } + } else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil { + tableInfo = currRs.TableInfo + } else { + return nil, errors.New("no table specified") + } + + newResultSet, err := rs.tableService.Scan(context.Background(), tableInfo) + if err != nil { + return nil, err + } + + return newResultSetProxy(newResultSet), nil +} + +var rsNextPageDoc = repl.Doc{ + Brief: "Returns the next page of the passed in result-set", + Usage: "RESULT_SET", + Args: []repl.ArgDoc{ + {Name: "result-set", Brief: "Result set to fetch the next page of"}, + }, + Detailed: ` + If no next page exists, the command will return nil. + `, +} + +func (rs *rsModule) rsNextPage(ctx context.Context, args ucl.CallArgs) (_ any, err error) { + var rsProxy SimpleProxy[*models.ResultSet] + + if err := args.Bind(&rsProxy); err != nil { + return nil, err + } + + if !rsProxy.value.HasNextPage() { + return nil, nil + } + + nextPage, err := rs.tableService.NextPage(ctx, rsProxy.value) + if err != nil { + return nil, err + } + + return newResultSetProxy(nextPage), nil +} + +func (rs *rsModule) rsUnion(ctx context.Context, args ucl.CallArgs) (_ any, err error) { + var rsProxy1, rsProxy2 SimpleProxy[*models.ResultSet] + + if err := args.Bind(&rsProxy1, &rsProxy2); err != nil { + return nil, err + } + + return newResultSetProxy(rsProxy1.ProxyValue().MergeWith(rsProxy2.ProxyValue())), nil +} + func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module { m := &rsModule{ tableService: tableService, @@ -138,8 +216,11 @@ func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module return ucl.Module{ Name: "rs", Builtins: map[string]ucl.BuiltinHandler{ - "new": m.rsNew, - "query": m.rsQuery, + "new": m.rsNew, + "query": m.rsQuery, + "scan": m.rsScan, + "next-page": m.rsNextPage, + "union": m.rsUnion, }, } } diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go index 113bd46..878ad3b 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go @@ -3,6 +3,7 @@ package cmdpacks_test import ( "fmt" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" "github.com/stretchr/testify/assert" "testing" ) @@ -13,7 +14,80 @@ func TestModRS_New(t *testing.T) { rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:new`) assert.NoError(t, err) - assert.IsType(t, rsProxy, &cmdpacks.ResultSetProxy{}) + assert.IsType(t, rsProxy, cmdpacks.SimpleProxy[*models.ResultSet]{}) +} + +func TestModRS_NextPage(t *testing.T) { + t.Run("multiple pages", func(t *testing.T) { + svc := newService(t, withDataGenerator(largeTestData), withTable("large-table"), withDefaultLimit(20)) + + hasNextPage, err := svc.CommandController.ExecuteAndWait(t.Context(), `@resultset.HasNextPage`) + assert.NoError(t, err) + assert.True(t, hasNextPage.(bool)) + + // Page 2 + rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset`) + + assert.NoError(t, err) + assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy) + assert.Equal(t, 20, len(rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue().Items())) + + hasNextPage, err = svc.CommandController.ExecuteAndWait(t.Context(), `(rs:next-page @resultset).HasNextPage`) + assert.NoError(t, err) + assert.True(t, hasNextPage.(bool)) + + // Page 3 + rsProxy, err = svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset | rs:next-page`) + + assert.NoError(t, err) + assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy) + assert.Equal(t, 10, len(rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue().Items())) + + hasNextPage, err = svc.CommandController.ExecuteAndWait(t.Context(), `(rs:next-page @resultset | rs:next-page).HasNextPage`) + assert.NoError(t, err) + assert.False(t, hasNextPage.(bool)) + + // Last page + rsProxy, err = svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page (rs:next-page @resultset | rs:next-page)`) + assert.NoError(t, err) + assert.Nil(t, rsProxy) + }) + + t.Run("only one page", func(t *testing.T) { + svc := newService(t) + + hasNextPage, err := svc.CommandController.ExecuteAndWait(t.Context(), `@resultset.HasNextPage`) + assert.NoError(t, err) + assert.False(t, hasNextPage.(bool)) + + rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset`) + assert.NoError(t, err) + assert.Nil(t, rsProxy) + }) +} + +func TestModRS_Union(t *testing.T) { + svc := newService(t, withDefaultLimit(2)) + + rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), ` + $mr = rs:union @resultset (rs:next-page @resultset) + + assert (eq (len $mr.Items) 3) "expected len == 3" + assert (eq $mr.Items.(0).pk "abc") "expected 0.pk" + assert (eq $mr.Items.(0).sk "111") "expected 0.sk" + assert (eq $mr.Items.(1).pk "abc") "expected 1.pk" + assert (eq $mr.Items.(1).sk "222") "expected 1.sk" + assert (eq $mr.Items.(2).pk "bbb") "expected 2.pk" + assert (eq $mr.Items.(2).sk "131") "expected 2.sk" + + $mr + `) + + assert.NoError(t, err) + assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy) + + rs := rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue() + assert.Equal(t, 3, len(rs.Items())) } func TestModRS_Query(t *testing.T) { @@ -65,11 +139,11 @@ func TestModRS_Query(t *testing.T) { res, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd) assert.NoError(t, err) - rs := res.(*cmdpacks.ResultSetProxy).RS + rs := res.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue() assert.Len(t, rs.Items(), len(tt.wantRows)) for i, rowIndex := range tt.wantRows { - for key, want := range testData[0].Data[rowIndex] { + for key, want := range svc.testData[0].Data[rowIndex] { have, ok := rs.Items()[i].AttributeValueAsString(key) assert.True(t, ok) assert.Equal(t, fmt.Sprint(want), have) diff --git a/internal/common/ui/commandctrl/cmdpacks/proxy.go b/internal/common/ui/commandctrl/cmdpacks/proxy.go index 5606f94..6801946 100644 --- a/internal/common/ui/commandctrl/cmdpacks/proxy.go +++ b/internal/common/ui/commandctrl/cmdpacks/proxy.go @@ -9,36 +9,50 @@ import ( "ucl.lmika.dev/ucl" ) -type proxyFields[T any] map[string]func(t T) ucl.Object - -type simpleProxy[T comparable] struct { - value T - fields proxyFields[T] +type proxyInfo[T comparable] struct { + fields map[string]func(t T) ucl.Object + lenFunc func(t T) int + strFunc func(t T) string } -func (tp simpleProxy[T]) String() string { +type SimpleProxy[T comparable] struct { + value T + proxyInfo *proxyInfo[T] +} + +func (tp SimpleProxy[T]) ProxyValue() T { + return tp.value +} + +func (tp SimpleProxy[T]) String() string { + if tp.proxyInfo.strFunc != nil { + return tp.proxyInfo.strFunc(tp.value) + } return fmt.Sprint(tp.value) } -func (tp simpleProxy[T]) Truthy() bool { +func (tp SimpleProxy[T]) Truthy() bool { var zeroT T return tp.value != zeroT } -func (tp simpleProxy[T]) Len() int { - return len(tp.fields) +func (tp SimpleProxy[T]) Len() int { + if tp.proxyInfo.lenFunc != nil { + return tp.proxyInfo.lenFunc(tp.value) + } + return len(tp.proxyInfo.fields) } -func (tp simpleProxy[T]) Value(k string) ucl.Object { - f, ok := tp.fields[k] +func (tp SimpleProxy[T]) Value(k string) ucl.Object { + f, ok := tp.proxyInfo.fields[k] if !ok { return nil } return f(tp.value) } -func (tp simpleProxy[T]) Each(fn func(k string, v ucl.Object) error) error { - for key := range maps.Keys(tp.fields) { +func (tp SimpleProxy[T]) Each(fn func(k string, v ucl.Object) error) error { + for key := range maps.Keys(tp.proxyInfo.fields) { if err := fn(key, tp.Value(key)); err != nil { return err } @@ -72,41 +86,63 @@ func (tp simpleProxyList[T]) Index(k int) ucl.Object { } func newResultSetProxy(rs *models.ResultSet) ucl.Object { - return simpleProxy[*models.ResultSet]{value: rs, fields: resultSetProxyFields} + return SimpleProxy[*models.ResultSet]{value: rs, proxyInfo: resultSetProxyFields} } -var resultSetProxyFields = proxyFields[*models.ResultSet]{ - "Table": func(t *models.ResultSet) ucl.Object { return newTableProxy(t.TableInfo) }, - "Items": func(t *models.ResultSet) ucl.Object { return resultSetItemsProxy{t} }, +var resultSetProxyFields = &proxyInfo[*models.ResultSet]{ + lenFunc: func(t *models.ResultSet) int { return len(t.Items()) }, + strFunc: func(t *models.ResultSet) string { + return fmt.Sprintf("ResultSet(%v:%d)", t.TableInfo.Name, len(t.Items())) + }, + fields: map[string]func(t *models.ResultSet) ucl.Object{ + "Table": func(t *models.ResultSet) ucl.Object { return newTableProxy(t.TableInfo) }, + "Items": func(t *models.ResultSet) ucl.Object { return resultSetItemsProxy{t} }, + "HasNextPage": func(t *models.ResultSet) ucl.Object { return ucl.BoolObject(t.HasNextPage()) }, + }, } func newTableProxy(table *models.TableInfo) ucl.Object { - return simpleProxy[*models.TableInfo]{value: table, fields: tableProxyFields} + return SimpleProxy[*models.TableInfo]{value: table, proxyInfo: tableProxyFields} } -var tableProxyFields = proxyFields[*models.TableInfo]{ - "Name": func(t *models.TableInfo) ucl.Object { return ucl.StringObject(t.Name) }, - "Keys": func(t *models.TableInfo) ucl.Object { return newKeyAttributeProxy(t.Keys) }, - "DefinedAttributes": func(t *models.TableInfo) ucl.Object { return ucl.StringListObject(t.DefinedAttributes) }, - "GSIs": func(t *models.TableInfo) ucl.Object { return newSimpleProxyList(t.GSIs, newGSIProxy) }, +var tableProxyFields = &proxyInfo[*models.TableInfo]{ + strFunc: func(t *models.TableInfo) string { + return fmt.Sprintf("Table(%v)", t.Name) + }, + fields: map[string]func(t *models.TableInfo) ucl.Object{ + "Name": func(t *models.TableInfo) ucl.Object { return ucl.StringObject(t.Name) }, + "Keys": func(t *models.TableInfo) ucl.Object { return newKeyAttributeProxy(t.Keys) }, + "DefinedAttributes": func(t *models.TableInfo) ucl.Object { return ucl.StringListObject(t.DefinedAttributes) }, + "GSIs": func(t *models.TableInfo) ucl.Object { return newSimpleProxyList(t.GSIs, newGSIProxy) }, + }, } func newKeyAttributeProxy(keyAttrs models.KeyAttribute) ucl.Object { - return simpleProxy[models.KeyAttribute]{value: keyAttrs, fields: keyAttributeProxyFields} + return SimpleProxy[models.KeyAttribute]{value: keyAttrs, proxyInfo: keyAttributeProxyFields} } -var keyAttributeProxyFields = proxyFields[models.KeyAttribute]{ - "PartitionKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) }, - "SortKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) }, +var keyAttributeProxyFields = &proxyInfo[models.KeyAttribute]{ + strFunc: func(t models.KeyAttribute) string { + return fmt.Sprintf("KeyAttribute(%v,%v)", t.PartitionKey, t.SortKey) + }, + fields: map[string]func(t models.KeyAttribute) ucl.Object{ + "PartitionKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) }, + "SortKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) }, + }, } func newGSIProxy(gsi models.TableGSI) ucl.Object { - return simpleProxy[models.TableGSI]{value: gsi, fields: gsiProxyFields} + return SimpleProxy[models.TableGSI]{value: gsi, proxyInfo: gsiProxyFields} } -var gsiProxyFields = proxyFields[models.TableGSI]{ - "Name": func(t models.TableGSI) ucl.Object { return ucl.StringObject(t.Name) }, - "Keys": func(t models.TableGSI) ucl.Object { return newKeyAttributeProxy(t.Keys) }, +var gsiProxyFields = &proxyInfo[models.TableGSI]{ + strFunc: func(t models.TableGSI) string { + return fmt.Sprintf("TableGSI(%v,(%v,%v))", t.Name, t.Keys.PartitionKey, t.Keys.SortKey) + }, + fields: map[string]func(t models.TableGSI) ucl.Object{ + "Name": func(t models.TableGSI) ucl.Object { return ucl.StringObject(t.Name) }, + "Keys": func(t models.TableGSI) ucl.Object { return newKeyAttributeProxy(t.Keys) }, + }, } type resultSetItemsProxy struct { @@ -114,7 +150,7 @@ type resultSetItemsProxy struct { } func (ip resultSetItemsProxy) String() string { - return "items" + return "RSItem()" } func (ip resultSetItemsProxy) Truthy() bool { @@ -136,7 +172,7 @@ type itemProxy struct { } func (ip itemProxy) String() string { - return "item" + return fmt.Sprintf("RSItems(%v)", len(ip.item)) } func (ip itemProxy) Truthy() bool { diff --git a/internal/common/ui/commandctrl/cmdpacks/pvars.go b/internal/common/ui/commandctrl/cmdpacks/pvars.go index d297a98..f07965a 100644 --- a/internal/common/ui/commandctrl/cmdpacks/pvars.go +++ b/internal/common/ui/commandctrl/cmdpacks/pvars.go @@ -26,9 +26,9 @@ func (rs resultSetPVar) Get(ctx context.Context) (any, error) { } func (rs resultSetPVar) Set(ctx context.Context, value any) error { - rsVal, ok := value.(simpleProxy[*models.ResultSet]) + rsVal, ok := value.(SimpleProxy[*models.ResultSet]) if !ok { - return errors.New("new value to @resultset is not a result set") + return errors.New("new value to @resultset is nil or not a result set") } msg := rs.readController.SetResultSet(rsVal.value) diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go index 48dd158..44aff3e 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go @@ -1,6 +1,8 @@ package cmdpacks_test import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" @@ -48,14 +50,41 @@ func TestStdCmds_Mark(t *testing.T) { } } +type testDataGenerator func() []testdynamo.TestData type services struct { CommandController *commandctrl.CommandController SelItemIndex int State *controllers.State + + settingStore *settingstore.SettingStore + table string + + testDataGenerator testDataGenerator + testData []testdynamo.TestData } -func newService(t *testing.T) *services { +type serviceOpt func(*services) + +func withDataGenerator(tg testDataGenerator) serviceOpt { + return func(s *services) { + s.testDataGenerator = tg + } +} + +func withTable(table string) serviceOpt { + return func(s *services) { + s.table = table + } +} + +func withDefaultLimit(limit int) serviceOpt { + return func(s *services) { + s.settingStore.SetDefaultLimit(limit) + } +} + +func newService(t *testing.T, opts ...serviceOpt) *services { ws := testworkspace.New(t) resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws) @@ -66,7 +95,18 @@ func newService(t *testing.T) *services { itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer()) inputHistoryService := inputhistory.New(inputHistoryStore) - client := testdynamo.SetupTestTable(t, testData) + s := &services{ + table: "service-test-data", + settingStore: settingStore, + testDataGenerator: normalTestData, + } + + for _, opt := range opts { + opt(s) + } + + s.testData = s.testDataGenerator() + client := testdynamo.SetupTestTable(t, s.testData) provider := dynamo.NewProvider(client) service := tables.NewService(provider, settingStore) @@ -74,6 +114,7 @@ func newService(t *testing.T) *services { state := controllers.NewState() jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true) + readController := controllers.NewTableReadController( state, service, @@ -84,7 +125,7 @@ func newService(t *testing.T) *services { eventBus, pasteboardprovider.NilProvider{}, nil, - "service-test-data", + s.table, ) writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) settingsController := controllers.NewSettingsController(settingStore, eventBus) @@ -102,10 +143,8 @@ func newService(t *testing.T) *services { }, ) - s := &services{ - State: state, - CommandController: commandController, - } + s.State = state + s.CommandController = commandController commandController.SetUIStateProvider(s) readController.Init() @@ -117,27 +156,57 @@ func (s *services) SelectedItemIndex() int { return s.SelItemIndex } -var testData = []testdynamo.TestData{ - { - TableName: "service-test-data", - Data: []map[string]interface{}{ - { - "pk": "abc", - "sk": "111", - "alpha": "This is some value", - }, - { - "pk": "abc", - "sk": "222", - "alpha": "This is another some value", - "beta": 1231, - }, - { - "pk": "bbb", - "sk": "131", - "beta": 2468, - "gamma": "foobar", +func (s *services) SetSelectedItemIndex(newIdx int) tea.Msg { + s.SelItemIndex = newIdx + return nil +} + +func normalTestData() []testdynamo.TestData { + return []testdynamo.TestData{ + { + TableName: "service-test-data", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, }, }, - }, + } +} + +func largeTestData() []testdynamo.TestData { + return []testdynamo.TestData{ + { + TableName: "large-table", + Data: genRow(50, func(i int) map[string]interface{} { + return map[string]interface{}{ + "pk": fmt.Sprint(i), + "sk": fmt.Sprint(i), + "alpha": fmt.Sprintf("row %v", i), + } + }), + }, + } +} + +func genRow(count int, mapFn func(int) map[string]interface{}) []map[string]interface{} { + result := make([]map[string]interface{}, count) + for i := 0; i < count; i++ { + result[i] = mapFn(i) + } + return result } diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index 03d0421..ff4e398 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -151,3 +151,37 @@ func (rs *ResultSet) Sort(criteria SortCriteria) { rs.sortCriteria = criteria Sort(rs.items, criteria) } + +func (rs *ResultSet) MergeWith(otherRS *ResultSet) *ResultSet { + type pksk struct { + pk types.AttributeValue + sk types.AttributeValue + } + + if !rs.TableInfo.Equal(otherRS.TableInfo) { + return nil + } + + itemsInI := make(map[pksk]Item) + newItems := make([]Item, 0, len(rs.Items())+len(otherRS.Items())) + for _, item := range rs.Items() { + pk, sk := item.PKSK(rs.TableInfo) + itemsInI[pksk{pk, sk}] = item + newItems = append(newItems, item) + } + + for _, item := range otherRS.Items() { + pk, sk := item.PKSK(rs.TableInfo) + if _, hasItem := itemsInI[pksk{pk, sk}]; !hasItem { + newItems = append(newItems, item) + } + } + + newResultSet := &ResultSet{ + Created: time.Now(), + TableInfo: rs.TableInfo, + } + newResultSet.SetItems(newItems) + + return newResultSet +} From 7ae99b009bd41ba059906d87d4dda76d04828e20 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Fri, 23 May 2025 22:04:41 +1000 Subject: [PATCH 09/10] ucl: added rs:set --- cmd/dynamo-browse/main.go | 20 +- .../common/ui/commandctrl/cmdpacks/modrs.go | 78 ++++++- .../common/ui/commandctrl/cmdpacks/modui.go | 195 ++++++++++++++++++ .../common/ui/commandctrl/cmdpacks/stdcmds.go | 25 +++ .../ui/commandctrl/cmdpacks/stdcmds_test.go | 13 +- internal/common/ui/commandctrl/commandctrl.go | 23 ++- internal/common/ui/commandctrl/ctx.go | 39 +++- internal/common/ui/events/resultset.go | 5 + internal/dynamo-browse/controllers/events.go | 8 - .../dynamo-browse/controllers/keybinding.go | 4 + .../dynamo-browse/controllers/tableread.go | 30 ++- .../dynamo-browse/controllers/tablewrite.go | 20 +- internal/dynamo-browse/ui/model.go | 6 +- 13 files changed, 400 insertions(+), 66 deletions(-) create mode 100644 internal/common/ui/commandctrl/cmdpacks/modui.go create mode 100644 internal/common/ui/events/resultset.go diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index aeaf4eb..862941d 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -158,18 +158,18 @@ func main() { } keyBindingService := keybindings_service.NewService(keyBindings) - keyBindingController := controllers.NewKeyBindingController(keyBindingService, scriptController) + keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil) - commandController := commandctrl.NewCommandController(inputHistoryService, - cmdpacks.StandardCommands{ - TableService: tableService, - State: state, - ReadController: tableReadController, - WriteController: tableWriteController, - ExportController: exportController, - KeyBindingController: keyBindingController, - }, + stdCommands := cmdpacks.NewStandardCommands( + tableService, + state, + tableReadController, + tableWriteController, + exportController, + keyBindingController, ) + + commandController := commandctrl.NewCommandController(inputHistoryService, stdCommands) commandController.AddCommandLookupExtension(scriptController) commandController.SetCommandCompletionProvider(columnsController) diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go index eea66b4..407120c 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -3,6 +3,7 @@ package cmdpacks import ( "context" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "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/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" @@ -65,21 +66,26 @@ var rsQueryDoc = repl.Doc{ `, } -func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) { +func parseQuery( + ctx context.Context, + args ucl.CallArgs, + currentRS *models.ResultSet, + tablesService *tables.Service, +) (*queryexpr.QueryExpr, *models.TableInfo, error) { var expr string if err := args.Bind(&expr); err != nil { - return nil, err + return nil, nil, err } q, err := queryexpr.Parse(expr) if err != nil { - return nil, err + return nil, nil, err } if args.NArgs() > 0 { var queryArgs ucl.Hashable if err := args.Bind(&queryArgs); err != nil { - return nil, err + return nil, nil, err } queryNames := map[string]string{} @@ -108,17 +114,26 @@ func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) if args.HasSwitch("table") { var tblName string if err := args.BindSwitch("table", &tblName); err != nil { - return nil, err + return nil, nil, err } - tableInfo, err = rs.tableService.Describe(ctx, tblName) + tableInfo, err = tablesService.Describe(ctx, tblName) if err != nil { - return nil, err + return nil, nil, err } - } else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil { - tableInfo = currRs.TableInfo + } else if currentRS != nil && currentRS.TableInfo != nil { + tableInfo = currentRS.TableInfo } else { - return nil, errors.New("no table specified") + return nil, nil, errors.New("no table specified") + } + + return q, tableInfo, nil +} + +func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) { + q, tableInfo, err := parseQuery(ctx, args, rs.state.ResultSet(), rs.tableService) + if err != nil { + return nil, err } newResultSet, err := rs.tableService.ScanOrQuery(context.Background(), tableInfo, q, nil) @@ -167,6 +182,20 @@ func (rs *rsModule) rsScan(ctx context.Context, args ucl.CallArgs) (_ any, err e return newResultSetProxy(newResultSet), nil } +func (rs *rsModule) rsFilter(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + rsProxy SimpleProxy[*models.ResultSet] + filter string + ) + + if err := args.Bind(&rsProxy, &filter); err != nil { + return nil, err + } + + newResultSet := rs.tableService.Filter(rsProxy.ProxyValue(), filter) + return newResultSetProxy(newResultSet), nil +} + var rsNextPageDoc = repl.Doc{ Brief: "Returns the next page of the passed in result-set", Usage: "RESULT_SET", @@ -207,6 +236,33 @@ func (rs *rsModule) rsUnion(ctx context.Context, args ucl.CallArgs) (_ any, err return newResultSetProxy(rsProxy1.ProxyValue().MergeWith(rsProxy2.ProxyValue())), nil } +func (rs *rsModule) rsSet(ctx context.Context, args ucl.CallArgs) (_ any, err error) { + var ( + item itemProxy + expr string + val ucl.Object + ) + + if err := args.Bind(&item, &expr, &val); err != nil { + return nil, err + } + + q, err := queryexpr.Parse(expr) + if err != nil { + return nil, err + } + + // TEMP + if err := q.SetEvalItem(item.item, &types.AttributeValueMemberS{Value: val.String()}); err != nil { + return nil, err + } + item.resultSet.SetDirty(item.idx, true) + commandctrl.QueueRefresh(ctx) + // END TEMP + + return item, nil +} + func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module { m := &rsModule{ tableService: tableService, @@ -219,8 +275,10 @@ func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module "new": m.rsNew, "query": m.rsQuery, "scan": m.rsScan, + "filter": m.rsFilter, "next-page": m.rsNextPage, "union": m.rsUnion, + "set": m.rsSet, }, } } diff --git a/internal/common/ui/commandctrl/cmdpacks/modui.go b/internal/common/ui/commandctrl/cmdpacks/modui.go new file mode 100644 index 0000000..6bcd6ce --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modui.go @@ -0,0 +1,195 @@ +package cmdpacks + +import ( + "context" + 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/services/tables" + "ucl.lmika.dev/ucl" +) + +type uiModule struct { + tableService *tables.Service + state *controllers.State + ckb *customKeyBinding + readController *controllers.TableReadController +} + +func (m *uiModule) uiPrompt(ctx context.Context, args ucl.CallArgs) (any, error) { + var prompt string + if err := args.Bind(&prompt); err != nil { + return nil, err + } + + resChan := make(chan string) + go func() { + commandctrl.PostMsg(ctx, events.PromptForInput(prompt, nil, func(value string) tea.Msg { + resChan <- value + return nil + })) + }() + + select { + case value := <-resChan: + return value, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (m *uiModule) uiConfirm(ctx context.Context, args ucl.CallArgs) (any, error) { + var prompt string + if err := args.Bind(&prompt); err != nil { + return nil, err + } + + resChan := make(chan bool) + go func() { + commandctrl.PostMsg(ctx, events.Confirm(prompt, func(value bool) tea.Msg { + resChan <- value + return nil + })) + }() + + select { + case value := <-resChan: + return value, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (m *uiModule) uiPromptTable(ctx context.Context, args ucl.CallArgs) (any, error) { + tables, err := m.tableService.ListTables(context.Background()) + if err != nil { + return nil, err + } + + resChan := make(chan string) + go func() { + commandctrl.PostMsg(ctx, controllers.PromptForTableMsg{ + Tables: tables, + OnSelected: func(tableName string) tea.Msg { + resChan <- tableName + return nil + }, + }) + }() + + select { + case value := <-resChan: + return value, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (m *uiModule) uiBind(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + bindName string + key string + inv ucl.Invokable + ) + + if args.NArgs() == 2 { + if err := args.Bind(&key, &inv); err != nil { + return nil, err + } + bindName = "custom." + key + } else { + if err := args.Bind(&bindName, &key, &inv); err != nil { + return nil, err + } + } + + invoker := commandctrl.GetInvoker(ctx) + + m.ckb.bindings[bindName] = func() tea.Msg { + return invoker.Invoke(inv, nil) + } + m.ckb.keyBindings[key] = bindName + return nil, nil +} + +func (m *uiModule) uiQuery(ctx context.Context, args ucl.CallArgs) (any, error) { + q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService) + if err != nil { + return nil, err + } + + commandctrl.PostMsg(ctx, m.readController.RunQuery(q, tableInfo)) + return nil, nil +} + +func (m *uiModule) uiFilter(ctx context.Context, args ucl.CallArgs) (any, error) { + var filter string + + if err := args.Bind(&filter); err != nil { + return nil, err + } + + commandctrl.PostMsg(ctx, m.readController.Filter(filter)) + return nil, nil +} + +func moduleUI( + tableService *tables.Service, + state *controllers.State, + readController *controllers.TableReadController, +) (ucl.Module, controllers.CustomKeyBindingSource) { + m := &uiModule{ + tableService: tableService, + state: state, + readController: readController, + ckb: &customKeyBinding{ + bindings: map[string]tea.Cmd{}, + keyBindings: map[string]string{}, + }, + } + + return ucl.Module{ + Name: "ui", + Builtins: map[string]ucl.BuiltinHandler{ + "prompt": m.uiPrompt, + "prompt-table": m.uiPromptTable, + "confirm": m.uiConfirm, + "query": m.uiQuery, + "filter": m.uiFilter, + "bind": m.uiBind, + }, + }, m.ckb +} + +type customKeyBinding struct { + bindings map[string]tea.Cmd + keyBindings map[string]string +} + +func (c *customKeyBinding) LookupBinding(theKey string) string { + return c.keyBindings[theKey] +} + +func (c *customKeyBinding) CustomKeyCommand(key string) tea.Cmd { + bindingName, ok := c.keyBindings[key] + if !ok { + return nil + } + + binding, ok := c.bindings[bindingName] + if !ok { + return nil + } + + return binding +} + +func (c *customKeyBinding) UnbindKey(key string) { + delete(c.keyBindings, key) +} + +func (c *customKeyBinding) Rebind(bindingName string, newKey string) error { + c.keyBindings[newKey] = bindingName + return nil +} diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index e5c1aab..73d2c9c 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -19,6 +19,30 @@ type StandardCommands struct { WriteController *controllers.TableWriteController ExportController *controllers.ExportController KeyBindingController *controllers.KeyBindingController + + modUI ucl.Module +} + +func NewStandardCommands( + tableService *tables.Service, + state *controllers.State, + readController *controllers.TableReadController, + writeController *controllers.TableWriteController, + exportController *controllers.ExportController, + keyBindingController *controllers.KeyBindingController, +) StandardCommands { + modUI, ckbs := moduleUI(tableService, state, readController) + keyBindingController.SetCustomKeyBindingSource(ckbs) + + return StandardCommands{ + TableService: tableService, + State: state, + ReadController: readController, + WriteController: writeController, + ExportController: exportController, + KeyBindingController: keyBindingController, + modUI: modUI, + } } var cmdQuitDoc = repl.Doc{ @@ -364,6 +388,7 @@ func (sc StandardCommands) cmdRebind(ctx context.Context, args ucl.CallArgs) (an func (sc StandardCommands) InstOptions() []ucl.InstOption { return []ucl.InstOption{ ucl.WithModule(moduleRS(sc.TableService, sc.State)), + ucl.WithModule(sc.modUI), } } diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go index 44aff3e..228c6be 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go @@ -14,8 +14,10 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/inputhistory" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs" + keybindings_service "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings" "github.com/lmika/dynamo-browse/test/testdynamo" "github.com/lmika/dynamo-browse/test/testworkspace" bus "github.com/lmika/events" @@ -132,15 +134,12 @@ func newService(t *testing.T, opts ...serviceOpt) *services { columnsController := controllers.NewColumnsController(readController, eventBus) exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{}) + keyBindingService := keybindings_service.NewService(keybindings.Default()) + keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil) + _ = settingsController commandController := commandctrl.NewCommandController(inputHistoryService, - cmdpacks.StandardCommands{ - State: state, - TableService: service, - ReadController: readController, - WriteController: writeController, - ExportController: exportController, - }, + cmdpacks.NewStandardCommands(service, state, readController, writeController, exportController, keyBindingController), ) s.State = state diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 79847f0..d4de9be 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -139,8 +139,26 @@ func (c *CommandController) ExecuteAndWait(ctx context.Context, commandInput str return c.uclInst.Eval(ctx, commandInput) } +func (c *CommandController) Invoke(invokable ucl.Invokable, args []any) (msg tea.Msg) { + execCtx := execContext{ctrl: c} + ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) + + res, err := invokable.Invoke(ctx, args) + if err != nil { + msg = events.Error(err) + } else if res != nil { + msg = events.StatusMsg(fmt.Sprint(res)) + } + if execCtx.requestRefresh { + c.postMessage(events.ResultSetUpdated{}) + } + + return msg +} + func (c *CommandController) cmdLooper() { - ctx := context.WithValue(context.Background(), commandCtlKey, c) + execCtx := execContext{ctrl: c} + ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) for { select { @@ -151,6 +169,9 @@ func (c *CommandController) cmdLooper() { } else if res != nil { c.postMessage(events.StatusMsg(fmt.Sprint(res))) } + if execCtx.requestRefresh { + c.postMessage(events.ResultSetUpdated{}) + } } } } diff --git a/internal/common/ui/commandctrl/ctx.go b/internal/common/ui/commandctrl/ctx.go index 7fc70ea..fef635b 100644 --- a/internal/common/ui/commandctrl/ctx.go +++ b/internal/common/ui/commandctrl/ctx.go @@ -3,33 +3,60 @@ package commandctrl import ( "context" tea "github.com/charmbracelet/bubbletea" + "ucl.lmika.dev/ucl" ) type commandCtlKeyType struct{} var commandCtlKey = commandCtlKeyType{} +type execContext struct { + ctrl *CommandController + requestRefresh bool +} + func PostMsg(ctx context.Context, msg tea.Msg) { - cmdCtl, ok := ctx.Value(commandCtlKey).(*CommandController) + cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) if ok { - cmdCtl.postMessage(msg) + cmdCtl.ctrl.postMessage(msg) } } func SelectedItemIndex(ctx context.Context) (int, bool) { - cmdCtl, ok := ctx.Value(commandCtlKey).(*CommandController) + cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) if !ok { return 0, false } - return cmdCtl.uiStateProvider.SelectedItemIndex(), true + return cmdCtl.ctrl.uiStateProvider.SelectedItemIndex(), true } func SetSelectedItemIndex(ctx context.Context, newIdx int) tea.Msg { - cmdCtl, ok := ctx.Value(commandCtlKey).(*CommandController) + cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) if !ok { return nil } - return cmdCtl.uiStateProvider.SetSelectedItemIndex(newIdx) + return cmdCtl.ctrl.uiStateProvider.SetSelectedItemIndex(newIdx) +} + +func GetInvoker(ctx context.Context) Invoker { + cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) + if !ok { + return nil + } + + return cmdCtl.ctrl +} + +func QueueRefresh(ctx context.Context) { + cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) + if !ok { + return + } + cmdCtl.requestRefresh = true +} + +type Invoker interface { + Invoke(invokable ucl.Invokable, args []any) tea.Msg } diff --git a/internal/common/ui/events/resultset.go b/internal/common/ui/events/resultset.go new file mode 100644 index 0000000..cce739a --- /dev/null +++ b/internal/common/ui/events/resultset.go @@ -0,0 +1,5 @@ +package events + +type ResultSetUpdated struct { + StatusMessage string +} diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index 7af1c41..ac0e74a 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -81,14 +81,6 @@ type PromptForTableMsg struct { OnSelected func(tableName string) tea.Msg } -type ResultSetUpdated struct { - statusMessage string -} - -func (rs ResultSetUpdated) StatusMessage() string { - return rs.statusMessage -} - type ShowColumnOverlay struct{} type HideColumnOverlay struct{} diff --git a/internal/dynamo-browse/controllers/keybinding.go b/internal/dynamo-browse/controllers/keybinding.go index 043248f..3b9dc76 100644 --- a/internal/dynamo-browse/controllers/keybinding.go +++ b/internal/dynamo-browse/controllers/keybinding.go @@ -20,6 +20,10 @@ func NewKeyBindingController(service *keybindings.Service, customBindingSource C } } +func (kb *KeyBindingController) SetCustomKeyBindingSource(customBindingSource CustomKeyBindingSource) { + kb.customBindingSource = customBindingSource +} + func (kb *KeyBindingController) Rebind(bindingName string, newKey string, force bool) tea.Msg { existingBinding := kb.findExistingBinding(newKey) if existingBinding == "" { diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index e7034a5..38946f3 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -182,6 +182,10 @@ func (c *TableReadController) PromptForQuery() tea.Msg { } } +func (c *TableReadController) RunQuery(q *queryexpr.QueryExpr, table *models.TableInfo) tea.Msg { + return c.runQuery(table, q, "", true, nil) +} + func (c *TableReadController) runQuery( tableInfo *models.TableInfo, query *queryexpr.QueryExpr, @@ -339,27 +343,31 @@ func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg { }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} } -func (c *TableReadController) Filter() tea.Msg { +func (c *TableReadController) PromptForFilter() tea.Msg { return events.PromptForInputMsg{ Prompt: "filter: ", History: c.inputHistoryService.Iter(context.Background(), filterInputHistoryCategory), OnDone: func(value string) tea.Msg { - resultSet := c.state.ResultSet() - if resultSet == nil { - return events.StatusMsg("Result-set is nil") - } - - 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, false, resultSetUpdateFilter)).Submit() + return c.Filter(value) }, } } +func (c *TableReadController) Filter(value string) tea.Msg { + resultSet := c.state.ResultSet() + if resultSet == nil { + return events.StatusMsg("Result-set is nil") + } + + 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, false, resultSetUpdateFilter)).Submit() +} + func (c *TableReadController) handleResultSetFromJobResult( filter string, pushbackStack, errIfEmpty bool, diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 033a0cb..8f248c5 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -44,7 +44,7 @@ func (twc *TableWriteController) ToggleMark(idx int) tea.Msg { resultSet.SetMark(idx, !resultSet.Marked(idx)) }) - return ResultSetUpdated{} + return events.ResultSetUpdated{} } func (twc *TableWriteController) NewItem() tea.Msg { @@ -148,7 +148,7 @@ func (twc *TableWriteController) setStringValue(idx int, attr *queryexpr.QueryEx }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } @@ -181,7 +181,7 @@ func (twc *TableWriteController) setToExpressionValue(idx int, attr *queryexpr.Q }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } @@ -205,7 +205,7 @@ func (twc *TableWriteController) setNumberValue(idx int, attr *queryexpr.QueryEx }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } @@ -234,7 +234,7 @@ func (twc *TableWriteController) setBoolValue(idx int, attr *queryexpr.QueryExpr }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } @@ -255,7 +255,7 @@ func (twc *TableWriteController) setNullValue(idx int, attr *queryexpr.QueryExpr }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} } func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg { @@ -291,7 +291,7 @@ func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} } func (twc *TableWriteController) PutItems() tea.Msg { @@ -351,8 +351,8 @@ func (twc *TableWriteController) PutItems() tea.Msg { } return rs, nil }).OnDone(func(rs *models.ResultSet) tea.Msg { - return ResultSetUpdated{ - statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"), + return events.ResultSetUpdated{ + StatusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"), } }).Submit() }, @@ -379,7 +379,7 @@ func (twc *TableWriteController) TouchItem(idx int) tea.Msg { if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 9acb70e..81f6926 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -276,10 +276,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case controllers.SetTableItemView: cmd := m.setMainViewIndex(msg.ViewIndex) return m, cmd - case controllers.ResultSetUpdated: + case events.ResultSetUpdated: return m, tea.Batch( m.tableView.Refresh(), - events.SetStatus(msg.StatusMessage()), + events.SetStatus(msg.StatusMessage), ) case tea.KeyMsg: // TODO: use modes here @@ -302,7 +302,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keyMap.PromptForQuery): return m, m.tableReadController.PromptForQuery case key.Matches(msg, m.keyMap.PromptForFilter): - return m, m.tableReadController.Filter + return m, m.tableReadController.PromptForFilter case key.Matches(msg, m.keyMap.FetchNextPage): return m, m.tableReadController.NextPage case key.Matches(msg, m.keyMap.ViewBack): From cae7509a76c4d22bda991b2c6fe489ce22cf9e33 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 25 May 2025 13:31:00 +1000 Subject: [PATCH 10/10] Almost feature complete - Added reading of UCL scripts - Added pasteboard commands - Added ui:command which will define a proc at the top-level --- cmd/dynamo-browse/main.go | 18 +++-- go.mod | 2 +- go.sum | 2 + .../common/ui/commandctrl/cmdpacks/modpb.go | 46 ++++++++++++ .../common/ui/commandctrl/cmdpacks/modrs.go | 28 +++++++- .../common/ui/commandctrl/cmdpacks/modui.go | 15 ++++ .../common/ui/commandctrl/cmdpacks/proxy.go | 4 +- .../common/ui/commandctrl/cmdpacks/stdcmds.go | 5 ++ internal/common/ui/commandctrl/commandctrl.go | 71 +++++++++++++++---- internal/common/ui/commandctrl/ctx.go | 1 + internal/dynamo-browse/controllers/export.go | 2 +- internal/dynamo-browse/ui/model.go | 38 ++++------ 12 files changed, 185 insertions(+), 47 deletions(-) create mode 100644 internal/common/ui/commandctrl/cmdpacks/modpb.go diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 862941d..43fec27 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -8,6 +8,7 @@ import ( "log" "net" "os" + "strings" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" @@ -45,6 +46,7 @@ func main() { var flagDefaultLimit = flag.Int("default-limit", 0, "default limit for queries and scans") var flagWorkspace = flag.String("w", "", "workspace file") var flagQuery = flag.String("q", "", "run query") + var flagExtDir = flag.String("ext-dir", "$HOME/.config/dynamo-browse/ext:$HOME/.config/dynamo-browse/.", "directory to search for extensions") flag.Parse() ctx := context.Background() @@ -128,7 +130,7 @@ func main() { exportController := controllers.NewExportController(state, tableService, jobsController, columnsController, pasteboardProvider) settingsController := controllers.NewSettingsController(settingStore, eventBus) keyBindings := keybindings.Default() - scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus) + //scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus) if *flagQuery != "" { if *flagTable == "" { @@ -167,10 +169,11 @@ func main() { tableWriteController, exportController, keyBindingController, + pasteboardProvider, ) commandController := commandctrl.NewCommandController(inputHistoryService, stdCommands) - commandController.AddCommandLookupExtension(scriptController) + //commandController.AddCommandLookupExtension(scriptController) commandController.SetCommandCompletionProvider(columnsController) model := ui.NewModel( @@ -182,7 +185,7 @@ func main() { jobsController, itemRendererService, commandController, - scriptController, + //scriptController, eventBus, keyBindingController, pasteboardProvider, @@ -196,8 +199,13 @@ func main() { p := tea.NewProgram(model, tea.WithAltScreen()) jobsController.SetMessageSender(p.Send) - scriptController.Init() - scriptController.SetMessageSender(p.Send) + //scriptController.Init() + //scriptController.SetMessageSender(p.Send) + + if err := commandController.LoadExtensions(context.Background(), strings.Split(*flagExtDir, string(os.PathListSeparator))); err != nil { + fmt.Printf("Unable to load extensions: %v", err) + } + go commandController.StartMessageSender(p.Send) log.Println("launching") diff --git a/go.mod b/go.mod index ac57b4e..3113de7 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-20250519120409-53b05b5ba6f8 // indirect + ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e // indirect ) diff --git a/go.sum b/go.sum index 0aafeb1..2b9e341 100644 --- a/go.sum +++ b/go.sum @@ -460,3 +460,5 @@ ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a h1:dzBBFCY50+MQcJaQ90swdDyjzag5 ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8 h1:h32JQi0d1MI86RaAMaEU7kvti4uSLX5XYe/nk2abApg= ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e h1:N+HzQUunDUvdjAzbSDtHQZVZ1k+XHbVgbNwmc+EKmlQ= +ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= diff --git a/internal/common/ui/commandctrl/cmdpacks/modpb.go b/internal/common/ui/commandctrl/cmdpacks/modpb.go new file mode 100644 index 0000000..528499b --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modpb.go @@ -0,0 +1,46 @@ +package cmdpacks + +import ( + "context" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider" + "ucl.lmika.dev/ucl" +) + +type pbModule struct { + pasteboardProvider *pasteboardprovider.Provider +} + +func (m pbModule) pbGet(ctx context.Context, args ucl.CallArgs) (any, error) { + s, ok := m.pasteboardProvider.ReadText() + if !ok { + return "", nil + } + return s, nil +} + +func (m pbModule) pbPut(ctx context.Context, args ucl.CallArgs) (any, error) { + var s string + if err := args.Bind(&s); err != nil { + return nil, err + } + if err := m.pasteboardProvider.WriteText([]byte(s)); err != nil { + return nil, err + } + return s, nil +} + +func modulePB( + pasteboardProvider *pasteboardprovider.Provider, +) ucl.Module { + m := &pbModule{ + pasteboardProvider: pasteboardProvider, + } + + return ucl.Module{ + Name: "pb", + Builtins: map[string]ucl.BuiltinHandler{ + "get": m.pbGet, + "put": m.pbPut, + }, + } +} diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go index 407120c..148723c 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -252,13 +252,36 @@ func (rs *rsModule) rsSet(ctx context.Context, args ucl.CallArgs) (_ any, err er return nil, err } - // TEMP + // TEMP: attribute is always S if err := q.SetEvalItem(item.item, &types.AttributeValueMemberS{Value: val.String()}); err != nil { return nil, err } item.resultSet.SetDirty(item.idx, true) commandctrl.QueueRefresh(ctx) - // END TEMP + + return item, nil +} + +func (rs *rsModule) rsDel(ctx context.Context, args ucl.CallArgs) (_ any, err error) { + var ( + item itemProxy + expr string + ) + + if err := args.Bind(&item, &expr); err != nil { + return nil, err + } + + q, err := queryexpr.Parse(expr) + if err != nil { + return nil, err + } + + if err := q.DeleteAttribute(item.item); err != nil { + return nil, err + } + item.resultSet.SetDirty(item.idx, true) + commandctrl.QueueRefresh(ctx) return item, nil } @@ -279,6 +302,7 @@ func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module "next-page": m.rsNextPage, "union": m.rsUnion, "set": m.rsSet, + "del": m.rsDel, }, } } diff --git a/internal/common/ui/commandctrl/cmdpacks/modui.go b/internal/common/ui/commandctrl/cmdpacks/modui.go index 6bcd6ce..26253e6 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modui.go +++ b/internal/common/ui/commandctrl/cmdpacks/modui.go @@ -17,6 +17,20 @@ type uiModule struct { readController *controllers.TableReadController } +func (m *uiModule) uiCommand(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + name string + cmd ucl.Invokable + ) + if err := args.Bind(&name, &cmd); err != nil { + return nil, err + } + + invoker := commandctrl.GetInvoker(ctx) + invoker.Inst().SetBuiltinInvokable(name, cmd) + return nil, nil +} + func (m *uiModule) uiPrompt(ctx context.Context, args ucl.CallArgs) (any, error) { var prompt string if err := args.Bind(&prompt); err != nil { @@ -152,6 +166,7 @@ func moduleUI( return ucl.Module{ Name: "ui", Builtins: map[string]ucl.BuiltinHandler{ + "command": m.uiCommand, "prompt": m.uiPrompt, "prompt-table": m.uiPromptTable, "confirm": m.uiConfirm, diff --git a/internal/common/ui/commandctrl/cmdpacks/proxy.go b/internal/common/ui/commandctrl/cmdpacks/proxy.go index 6801946..8dad249 100644 --- a/internal/common/ui/commandctrl/cmdpacks/proxy.go +++ b/internal/common/ui/commandctrl/cmdpacks/proxy.go @@ -126,8 +126,8 @@ var keyAttributeProxyFields = &proxyInfo[models.KeyAttribute]{ return fmt.Sprintf("KeyAttribute(%v,%v)", t.PartitionKey, t.SortKey) }, fields: map[string]func(t models.KeyAttribute) ucl.Object{ - "PartitionKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) }, - "SortKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) }, + "PK": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) }, + "SK": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) }, }, } diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index 73d2c9c..9019ec6 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -6,6 +6,7 @@ import ( "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/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" "github.com/pkg/errors" "ucl.lmika.dev/repl" @@ -19,6 +20,7 @@ type StandardCommands struct { WriteController *controllers.TableWriteController ExportController *controllers.ExportController KeyBindingController *controllers.KeyBindingController + PBProvider *pasteboardprovider.Provider modUI ucl.Module } @@ -30,6 +32,7 @@ func NewStandardCommands( writeController *controllers.TableWriteController, exportController *controllers.ExportController, keyBindingController *controllers.KeyBindingController, + pbProvider *pasteboardprovider.Provider, ) StandardCommands { modUI, ckbs := moduleUI(tableService, state, readController) keyBindingController.SetCustomKeyBindingSource(ckbs) @@ -41,6 +44,7 @@ func NewStandardCommands( WriteController: writeController, ExportController: exportController, KeyBindingController: keyBindingController, + PBProvider: pbProvider, modUI: modUI, } } @@ -389,6 +393,7 @@ func (sc StandardCommands) InstOptions() []ucl.InstOption { return []ucl.InstOption{ ucl.WithModule(moduleRS(sc.TableService, sc.State)), ucl.WithModule(sc.modUI), + ucl.WithModule(modulePB(sc.PBProvider)), } } diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index d4de9be..bfbe79b 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -1,6 +1,7 @@ package commandctrl import ( + "bytes" "context" "fmt" tea "github.com/charmbracelet/bubbletea" @@ -136,7 +137,7 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms } func (c *CommandController) ExecuteAndWait(ctx context.Context, commandInput string) (any, error) { - return c.uclInst.Eval(ctx, commandInput) + return c.uclInst.EvalString(ctx, commandInput) } func (c *CommandController) Invoke(invokable ucl.Invokable, args []any) (msg tea.Msg) { @@ -202,7 +203,47 @@ func (c *CommandController) lookupCommand(name string) Command { return nil } -func (c *CommandController) ExecuteFile(filename string) error { +func (c *CommandController) LoadExtensions(ctx context.Context, baseDirs []string) error { + log.Printf("loading extensions: %v", baseDirs) + for _, baseDir := range baseDirs { + baseDir = os.ExpandEnv(baseDir) + descendIntoSubDirs := !strings.HasSuffix(baseDir, ".") + + if stat, err := os.Stat(baseDir); err != nil { + if os.IsNotExist(err) { + continue + } + return err + } else if !stat.IsDir() { + continue + } + + log.Printf("walking %v", baseDir) + if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if !descendIntoSubDirs && path != baseDir { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(info.Name(), ".ucl") { + if err := c.ExecuteFile(ctx, path); err != nil { + log.Println(err) + } + log.Printf("loaded %v\n", path) + } + return nil + }); err != nil { + return err + } + } + return nil +} + +func (c *CommandController) ExecuteFile(ctx context.Context, filename string) error { oldInteractive := c.interactive c.interactive = false defer func() { @@ -211,27 +252,31 @@ func (c *CommandController) ExecuteFile(filename string) error { baseFilename := filepath.Base(filename) + execCtx := execContext{ctrl: c} + ctx = context.WithValue(context.Background(), commandCtlKey, &execCtx) + if rcFile, err := os.ReadFile(filename); err == nil { - if err := c.executeFile(rcFile, baseFilename); err != nil { - return errors.Wrapf(err, "error executing %v", filename) + if err := c.executeFile(ctx, rcFile); err != nil { + return errors.Wrapf(err, "error executing %v", baseFilename) } } else { - return errors.Wrapf(err, "error loading %v", filename) + return errors.Wrapf(err, "error loading %v", baseFilename) } return nil } -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)) - //} +func (c *CommandController) executeFile(ctx context.Context, file []byte) error { + if _, err := c.uclInst.Eval(ctx, bytes.NewReader(file), ucl.WithSubEnv()); err != nil { + return err + } + return nil } +func (c *CommandController) Inst() *ucl.Inst { + return c.uclInst +} + func (c *CommandController) cmdInvoker(ctx context.Context, name string, args ucl.CallArgs) (any, error) { command := c.lookupCommand(name) if command == nil { diff --git a/internal/common/ui/commandctrl/ctx.go b/internal/common/ui/commandctrl/ctx.go index fef635b..dffff70 100644 --- a/internal/common/ui/commandctrl/ctx.go +++ b/internal/common/ui/commandctrl/ctx.go @@ -59,4 +59,5 @@ func QueueRefresh(ctx context.Context) { type Invoker interface { Invoke(invokable ucl.Invokable, args []any) tea.Msg + Inst() *ucl.Inst } diff --git a/internal/dynamo-browse/controllers/export.go b/internal/dynamo-browse/controllers/export.go index 7f4ce8e..23c91a9 100644 --- a/internal/dynamo-browse/controllers/export.go +++ b/internal/dynamo-browse/controllers/export.go @@ -107,7 +107,7 @@ func (c *ExportController) ExportCSVToClipboard() tea.Msg { if err := c.pasteboardProvider.WriteText(bts.Bytes()); err != nil { return events.Error(err) } - return nil + return events.StatusMsg("Table copied to clipboard") } // TODO: this really needs to be a service! diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 81f6926..90dfa0f 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -22,7 +22,6 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils" bus "github.com/lmika/events" "log" - "os" ) const ( @@ -33,8 +32,6 @@ const ( ViewModeTableOnly = 4 ViewModeCount = 5 - - initRCFilename = "$HOME/.config/audax/dynamo-browse/init.rc" ) type Model struct { @@ -43,14 +40,14 @@ type Model struct { settingsController *controllers.SettingsController exportController *controllers.ExportController commandController *commandctrl.CommandController - scriptController *controllers.ScriptController - jobController *controllers.JobsController - colSelector *colselector.Model - relSelector *relselector.Model - itemEdit *dynamoitemedit.Model - statusAndPrompt *statusandprompt.StatusAndPrompt - tableSelect *tableselect.Model - eventBus *bus.Bus + //scriptController *controllers.ScriptController + jobController *controllers.JobsController + colSelector *colselector.Model + relSelector *relselector.Model + itemEdit *dynamoitemedit.Model + statusAndPrompt *statusandprompt.StatusAndPrompt + tableSelect *tableselect.Model + eventBus *bus.Bus mainViewIndex int @@ -71,7 +68,7 @@ func NewModel( jobController *controllers.JobsController, itemRendererService *itemrenderer.Service, cc *commandctrl.CommandController, - scriptController *controllers.ScriptController, + //scriptController *controllers.ScriptController, eventBus *bus.Bus, keyBindingController *controllers.KeyBindingController, pasteboardProvider services.PasteboardProvider, @@ -255,7 +252,8 @@ func NewModel( tableReadController: rc, tableWriteController: wc, commandController: cc, - scriptController: scriptController, + //scriptController: scriptController, + exportController: exportController, jobController: jobController, itemEdit: itemEdit, colSelector: colSelector, @@ -318,10 +316,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // return m, nil case key.Matches(msg, m.keyMap.ShowColumnOverlay): return m, events.SetTeaMessage(controllers.ShowColumnOverlay{}) - case key.Matches(msg, m.keyMap.ShowRelItemsOverlay): - if idx := m.tableView.SelectedItemIndex(); idx >= 0 { - return m, events.SetTeaMessage(m.scriptController.LookupRelatedItems(idx)) - } + //case key.Matches(msg, m.keyMap.ShowRelItemsOverlay): + // if idx := m.tableView.SelectedItemIndex(); idx >= 0 { + // return m, events.SetTeaMessage(m.scriptController.LookupRelatedItems(idx)) + // } case key.Matches(msg, m.keyMap.PromptForCommand): return m, m.commandController.Prompt case key.Matches(msg, m.keyMap.PromptForTable): @@ -344,12 +342,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) Init() tea.Cmd { - // TODO: this should probably be moved somewhere else - rcFilename := os.ExpandEnv(initRCFilename) - if err := m.commandController.ExecuteFile(rcFilename); err != nil { - log.Println(err) - } - return tea.Batch( m.tableReadController.Init, m.root.Init(),