dynamo-browse/internal/common/ui/commandctrl/commandctrl.go
Leon Mika 32ae488066
All checks were successful
ci / build (push) Successful in 3m17s
Moved package to lmika.dev/cmd/dynamo-browse
2025-05-26 22:04:23 +10:00

325 lines
7.5 KiB
Go

package commandctrl
import (
"bytes"
"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"
"lmika.dev/cmd/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, error) {
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)
}
execCtx := execContext{ctrl: cc}
ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx)
for _, pkg := range pkgs {
if err := pkg.RunPrelude(ctx, cc.uclInst); err != nil {
return nil, err
}
}
go cc.cmdLooper()
return cc, nil
}
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.EvalString(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) 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() {
c.interactive = oldInteractive
}()
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(ctx, rcFile); err != nil {
return errors.Wrapf(err, "error executing %v", baseFilename)
}
} else {
return errors.Wrapf(err, "error loading %v", baseFilename)
}
return nil
}
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 {
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
}