package commandctrl import ( "context" "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" "log" "os" "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" ) const commandsCategory = "commands" type cmdMessage struct { cmd string } type CommandController struct { uclInst *ucl.Inst historyProvider IterProvider commandList *CommandList lookupExtensions []CommandLookupExtension completionProvider CommandCompletionProvider uiStateProvider UIStateProvider cmdChan chan cmdMessage msgChan chan tea.Msg interactive bool } 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, } 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) } go cc.cmdLooper() return cc } func (c *CommandController) AddCommands(ctx *CommandList) { ctx.parent = c.commandList c.commandList = ctx } func (c *CommandController) StartMessageSender(msgSender func(tea.Msg)) { for msg := range c.msgChan { msgSender(msg) } } func (c *CommandController) SetUIStateProvider(provider UIStateProvider) { c.uiStateProvider = provider } func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) { c.lookupExtensions = append(c.lookupExtensions, ext) } func (c *CommandController) SetCommandCompletionProvider(provider CommandCompletionProvider) { c.completionProvider = provider } func (c *CommandController) Prompt() tea.Msg { return events.PromptForInputMsg{ Prompt: ":", History: c.historyProvider.Iter(context.Background(), commandsCategory), OnDone: func(value string) tea.Msg { return c.Execute(value) }, // TEMP OnTabComplete: func(value string) (string, bool) { if c.completionProvider == nil { return "", false } if strings.HasPrefix(value, "sa ") || strings.HasPrefix(value, "da ") { tokens := shellwords.Split(strings.TrimSpace(value)) lastToken := tokens[len(tokens)-1] options := c.completionProvider.AttributesWithPrefix(lastToken) if len(options) == 1 { return value[:len(value)-len(lastToken)] + options[0], true } } return "", false }, // END TEMP } } func (c *CommandController) Execute(commandInput string) tea.Msg { return c.execute(ExecContext{FromFile: false}, commandInput) } func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Msg { input := strings.TrimSpace(commandInput) if input == "" { return nil } select { case c.cmdChan <- cmdMessage{cmd: input}: // good default: return events.Error(errors.New("command currently running")) } return nil } func (c *CommandController) ExecuteAndWait(ctx context.Context, commandInput string) (any, error) { 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() { execCtx := execContext{ctrl: c} ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) for { select { case cmdChan := <-c.cmdChan: res, err := c.ExecuteAndWait(ctx, cmdChan.cmd) if err != nil { c.postMessage(events.Error(err)) } else if res != nil { c.postMessage(events.StatusMsg(fmt.Sprint(res))) } if execCtx.requestRefresh { c.postMessage(events.ResultSetUpdated{}) } } } } func (c *CommandController) Alias(commandName string) Command { return func(ctx ExecContext, args ucl.CallArgs) tea.Msg { command := c.lookupCommand(commandName) if command == nil { return events.Error(errors.New("no such command: " + commandName)) } return command(ctx, args) } } func (c *CommandController) lookupCommand(name string) Command { for ctx := c.commandList; ctx != nil; ctx = ctx.parent { if cmd, ok := ctx.Commands[name]; ok { return cmd } } for _, exts := range c.lookupExtensions { if cmd := exts.LookupCommand(name); cmd != nil { return cmd } } return nil } 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 { if err := c.executeFile(rcFile, baseFilename); err != nil { return errors.Wrapf(err, "error executing %v", filename) } } else { return errors.Wrapf(err, "error loading %v", filename) } 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)) //} return nil } func (c *CommandController) cmdInvoker(ctx context.Context, name string, args ucl.CallArgs) (any, error) { command := c.lookupCommand(name) if command == nil { return nil, errors.New("no such command: " + name) } res := command(ExecContext{}, args) if errMsg, isErrMsg := res.(events.ErrorMsg); isErrMsg { return nil, errMsg } return teaMsgWrapper{res}, nil } func (c *CommandController) printLine(s string) { if c.msgChan == nil || !c.interactive { log.Println(s) return } select { case c.msgChan <- events.StatusMsg(s): default: log.Println(s) } } func (c *CommandController) postMessage(msg tea.Msg) { if c.msgChan == nil { return } c.msgChan <- msg } type teaMsgWrapper struct { msg tea.Msg }