diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-18 08:39:04 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-18 08:39:04 +0300 |
| commit | 7bca02bddcc88002e8edf72d2f20f526cf9d2668 (patch) | |
| tree | 7bd907e787628393d52a3281ce751d2f807421ac /internal/cli/cli_dispatch.go | |
| parent | 5874a7a5d3cac75b76bdf2f7793a05ce3c45b4d7 (diff) | |
refactor: split cli.go into focused files by responsibility
Extract cli.go into four dedicated files to improve separation of concerns
and readability: cli_flags.go (flag parsing), cli_backend.go (backend init),
cli_commands.go (command handlers), cli_dispatch.go (command routing).
Fix inaccurate comments on logMsg/warn that claimed declaration order matters
in Go; package-level functions are resolved across the whole file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/cli/cli_dispatch.go')
| -rw-r--r-- | internal/cli/cli_dispatch.go | 301 |
1 files changed, 301 insertions, 0 deletions
diff --git a/internal/cli/cli_dispatch.go b/internal/cli/cli_dispatch.go new file mode 100644 index 0000000..ef23cd5 --- /dev/null +++ b/internal/cli/cli_dispatch.go @@ -0,0 +1,301 @@ +// Package cli — shell loop and command dispatcher. +// +// This file implements the interactive readline shell loop (shellLoop) and the +// two-tier command dispatcher (dispatch → dispatchSimple | dispatchSearch). +// It intentionally contains no flag parsing or backend construction; those +// responsibilities live in cli_flags.go and cli_backend.go respectively. +package cli + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "codeberg.org/snonux/foostore/internal/store" + "codeberg.org/snonux/foostore/internal/version" +) + +// run dispatches a single command (when argv is non-empty and no shell flag is +// set) or enters the interactive shell loop. Returns an exit code. +func (c *CLI) run(ctx context.Context, argv []string) int { + // Enter shell mode when: no arguments, $FOOSTORE_SHELL is set, or the first + // argument is "shell". Mirrors the Ruby shell_loop entry conditions. + enterShell := len(argv) == 0 || + os.Getenv("FOOSTORE_SHELL") != "" || + (len(argv) > 0 && argv[0] == "shell") + + if enterShell { + return c.shellLoop(ctx) + } + + return c.dispatch(ctx, argv) +} + +// shellLoop runs the interactive readline loop, reading commands until the +// user presses Ctrl+D (EOF) or types "exit". Mirrors Ruby#shell_loop. +// c.lastResult is updated by dispatch and accessible between iterations. +func (c *CLI) shellLoop(ctx context.Context) int { + ec := 0 + logMsg("Interactive mode (vi keys): Ctrl-] for normal mode, i for insert | Enter fuzzy picker | ctrl-t/y/o/e (or alt-t/y/o/e) in picker") + + for { + line, err := c.sh.ReadLine(ctx) + if err == io.EOF { + break // Ctrl+D — clean exit. + } + if err != nil { + warn(fmt.Sprintf("readline error: %v", err)) + continue + } + + argv := strings.Fields(line) + if len(argv) == 0 { + ec = c.handleEmptyInput(ctx) + continue + } + + // "last" and "exit" are intercepted before dispatch. + if argv[0] == "last" { + fmt.Println(c.lastResult) + continue + } + if argv[0] == "exit" { + logMsg("Good bye") + break + } + + ec = c.dispatch(ctx, argv) + } + + return ec +} + +// handleEmptyInput handles an empty input line in the shell loop by running +// the fzf picker. Updates c.lastResult when a selection is made and +// dispatches the chosen picker action. Returns an exit code. +func (c *CLI) handleEmptyInput(ctx context.Context) int { + result, fzfErr := c.st.FzfInteractive(ctx) + if fzfErr != nil { + warn(fzfErr.Error()) + return 1 + } + if result.Description != "" { + c.lastResult = result.Description + logMsg(fmt.Sprintf("Picked: %s", result.Description)) + return c.dispatchPickerAction(ctx, result) + } + return 0 +} + +// pickerActionArgv maps a picker action to the argv slice for dispatch. +// Returns nil for actions that do not map to a CLI command (e.g. PickerSelect). +func pickerActionArgv(action store.PickerAction, description string) []string { + switch action { + case store.PickerCat: + return []string{"cat", description} + case store.PickerPaste: + return []string{"paste", description} + case store.PickerOpen: + return []string{"open", description} + case store.PickerEdit: + return []string{"edit", description} + default: + return nil + } +} + +// dispatchPickerAction converts a PickerResult into an argv and dispatches it. +// Returns 0 for picker actions that have no CLI command equivalent. +func (c *CLI) dispatchPickerAction(ctx context.Context, result store.PickerResult) int { + argv := pickerActionArgv(result.Action, result.Description) + if len(argv) == 0 { + return 0 + } + return c.dispatch(ctx, argv) +} + +// dispatch routes a parsed argv slice to the appropriate handler. +// It returns an exit code and updates c.lastResult when a non-empty result +// is produced. The function delegates to dispatchSimple and dispatchSearch. +func (c *CLI) dispatch(ctx context.Context, argv []string) int { + if len(argv) == 0 { + result, err := c.st.Fzf(ctx) + if err != nil { + warn(err.Error()) + return 1 + } + if result != "" { + c.lastResult = result + } + return 0 + } + + cmd := argv[0] + + // Commands handled by dispatchSimple (no search term needed). + if ec, result, handled := c.dispatchSimple(ctx, argv, cmd); handled { + if result != "" { + c.lastResult = result + } + return ec + } + + // Commands that require a search term (argv[1] or fallback to c.lastResult). + ec, result := c.dispatchSearch(ctx, argv, cmd) + if result != "" { + c.lastResult = result + } + return ec +} + +// dispatchSimple handles commands that don't require a search term. +// Returns (exitCode, lastResult, handled). handled=false means the command +// is not in this set and should fall through to dispatchSearch. +func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (int, string, bool) { + switch cmd { + case "ls": + indexes, err := c.st.Search(ctx, ".", store.ActionNone, nil, printIndex) + if err != nil { + warn(err.Error()) + return 1, "", true + } + logMsg(fmt.Sprintf("%d entries", len(indexes))) + return 0, "", true + case "add": + return c.cmdAdd(ctx, argv), "", true + case "import": + return c.cmdImport(ctx, argv), "", true + case "import_r": + return c.cmdImportR(ctx, argv), "", true + case "sync": + return c.dispatchGitOp(func() error { return c.g.Sync(ctx, c.cfg.SyncRepos) }) + case "status": + return c.dispatchGitOp(func() error { return c.g.Status(ctx) }) + case "commit": + return c.dispatchGitOp(func() error { return c.g.Commit(ctx) }) + case "reset": + return c.dispatchGitOp(func() error { return c.g.Reset(ctx) }) + case "fullcommit": + return c.cmdFullCommit(ctx), "", true + case "shred": + return c.dispatchGitOp(func() error { return c.st.ShredAllExported(ctx) }) + case "migrate-kdbx": + return c.cmdMigrateKDBX(ctx, argv), "", true + case "version": + logMsg(fmt.Sprintf("foostore %s", version.Version)) + return 0, "", true + case "commands": + for _, name := range CommandList { + fmt.Println(name) + } + return 0, "", true + case "help": + printHelp() + return 0, "", true + case "shell": + // When typed in the shell loop, "shell" is intercepted by run() before + // dispatch is called, so this branch only fires in one-shot mode where + // switching to interactive mode is not meaningful. + logMsg("Use foostore without arguments to enter interactive mode") + return 0, "", true + case "exit": + logMsg("Good bye") + return 0, "", true + case "last": + // In shell mode "last" is intercepted by shellLoop; in one-shot mode + // there is no persistent lastResult, so we print the zero value. + fmt.Println(c.lastResult) + return 0, "", true + } + + // Not a simple command — let dispatchSearch handle it. + return 0, "", false +} + +// dispatchGitOp is a small adapter that wraps a no-argument git (or store) +// operation into the (exitCode, lastResult, handled) triple expected by +// dispatchSimple. Prints a warning and returns exit code 1 on error. +// The context.Context parameter is omitted entirely: each op closure already +// captures ctx from its call site in dispatchSimple, so no forwarding is needed. +func (c *CLI) dispatchGitOp(op func() error) (int, string, bool) { + if err := op(); err != nil { + warn(err.Error()) + return 1, "", true + } + return 0, "", true +} + +// dispatchSearch handles commands that accept a search term: search, cat, +// paste, get, export, pathexport, open, edit, rm, and the catch-all. +// When no explicit term is supplied, c.lastResult is used as the fallback, +// mirroring Ruby's `search_term = argv.length < 2 ? last_result : argv[1]`. +func (c *CLI) dispatchSearch(ctx context.Context, argv []string, cmd string) (int, string) { + term := c.lastResult // fallback to last search result when no term given + if len(argv) > 1 { + term = argv[1] + } + + switch cmd { + case "search": + return c.cmdSearchOnly(ctx, term) + case "get": + // "get" is an alias for "cat". + return c.cmdSearchAction(ctx, term, store.ActionCat, nil) + case "rm": + if err := c.st.Remove(ctx, term, os.Stdin); err != nil { + warn(err.Error()) + return 1, "" + } + return 0, "" + case "cat", "paste", "export", "pathexport", "open", "edit": + action := SearchActions[cmd] + return c.cmdSearchAction(ctx, term, action, c.makeActionFn(ctx, action)) + default: + // Unknown command: treat as a search term, mirroring Ruby's else branch. + // This allows bare search terms to be typed without prefixing "search". + return c.cmdSearchFallback(ctx, cmd) + } +} + +// cmdSearchFallback treats an unrecognised command as a search term. This +// mirrors the Ruby else branch in dispatch and allows users to type bare +// search terms in the shell loop. +func (c *CLI) cmdSearchFallback(ctx context.Context, term string) (int, string) { + indexes, err := c.st.Search(ctx, term, store.ActionNone, nil, printIndex) + if err != nil { + warn(err.Error()) + return 1, "" + } + if len(indexes) > 0 { + return 0, indexes[0].Description + } + return 0, "" +} + +// cmdSearchOnly runs a search without any action and returns the result. +func (c *CLI) cmdSearchOnly(ctx context.Context, term string) (int, string) { + indexes, err := c.st.Search(ctx, term, store.ActionNone, nil, printIndex) + if err != nil { + warn(err.Error()) + return 1, "" + } + if len(indexes) > 0 { + return 0, indexes[0].Description + } + return 0, "" +} + +// cmdSearchAction runs a search with the given action and optional callback. +func (c *CLI) cmdSearchAction(ctx context.Context, term string, action store.Action, actionFn func(context.Context, *store.Index, *store.Data) error) (int, string) { + indexes, err := c.st.Search(ctx, term, action, actionFn, printIndex) + if err != nil { + warn(err.Error()) + return 1, "" + } + if len(indexes) > 0 { + return 0, indexes[0].Description + } + return 0, "" +} |
