diff --git a/CONTEXT.md b/CONTEXT.md index ad2ce9b..5cf3782 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,7 +13,7 @@ Changing the shell's working directory to a chosen Project. _Avoid_: cd, navigate, switch, open, go to **Visit**: -A single recorded Jump to a Project, or an entry seeded for a Project by a Scan. Changing directory by other means is not tracked. +A single recorded Jump to a Project, or Action run on one, or an entry seeded for a Project by a Scan. Changing directory by other means is not tracked. _Avoid_: Access, hit, entry, usage **Stale Visit**: @@ -40,6 +40,10 @@ _Avoid_: Search, filter string, pattern One of the arrangements in which the Picker draws Projects. The List Layout, the only one today, draws them in one flat run. _Avoid_: View, mode, style, theme, skin +**Action**: +A named command bound to a key in the Picker and run on the selected Project. Built-in Actions can be overridden by name, field by field, and the user can define others in `config.toml`. It may Jump once its command exits, or detach, running while the Picker stays open. +_Avoid_: Command, binding, hotkey, shortcut + **Wrapper**: The shell function installed into the user's shell that turns a Project chosen in the Picker into a Jump. _Avoid_: Hook, integration, plugin, shim, alias diff --git a/README.md b/README.md index 12b7520..9614390 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,45 @@ Vim key map (`keys.vim = true`): the list is focused on open. | `esc` / `q` | cancel | | `enter` | Jump to the selected Project | +### Actions + +An **Action** is a named command bound to a key and run on the selected +Project. Define one in `config.toml`: + +```toml +[actions.code] +key = "ctrl+v" +run = "code {path}" # {path} is the shell-quoted absolute path +detach = true # start without waiting; the Picker stays open + +[actions.lazygit] +key = "ctrl+l" +run = "lazygit" # runs with the Project as its working directory +``` + +- `run` is handed to `sh -c` with the Project as the working directory and + `$CDD_PATH` set to its path. Use `{path}`, not `$path`, which zsh ties to + `$PATH`. +- By default the Picker quits and the command gets your terminal for stdin, + stdout and stderr, so a TUI like `lazygit` works. `cdd` waits and exits + with its status. Nothing is Jumped to afterwards, unless `jump = true`, + which Jumps to the Project once the command exits successfully. +- `detach = true` starts the command in its own session without waiting, + discarding its output, and the Picker stays open. If it cannot be + started, the reason shows on the Picker's last line until the next key. +- Every run records a Visit to the Project. + +Key names are those the Picker recognises: `ctrl+x`, `alt+x`, `enter`, +`f1`, and so on. A binding takes the key away from its navigation use +(`ctrl+n` bound means `↓` is the only way down), but `esc` and `ctrl+c` +can never be bound. A plain printable key (`a`, `?`) would steal typing, so +it is an error unless `keys.vim = true`, where it applies in list focus. +Two Actions on one key is an error, and `key = ""` leaves an Action +unbound. Each of these is reported with the line it is on. + +An `[actions.]` table whose name is a built-in Action overrides only +the fields it sets. There are no built-in Actions yet. + ### Layout The Picker draws the **List Layout**: a flat fzf-style run of Projects in @@ -166,6 +205,9 @@ layout = "list" - `[keys].vim`: when `true`, the Picker opens with the list focused and uses the vim key map described above. Defaults to `false`, the default key map. +- `[actions.]`: an Action, with `key`, `run`, `jump` and `detach`; + see [Actions](#actions). Not in the defaults above, since there is no + Action unless you define one. - `[picker].layout`: which Layout the Picker draws. `"list"`, described above, is the default and, for now, the only one. Any other value is a config error. diff --git a/internal/action/action.go b/internal/action/action.go new file mode 100644 index 0000000..f654783 --- /dev/null +++ b/internal/action/action.go @@ -0,0 +1,130 @@ +// Package action defines the Actions the Picker can run on the selected +// Project: the built-in ones, a user's overrides of them from config.toml, +// and the Runner that starts their commands. +package action + +import ( + "errors" + "fmt" + "slices" + "sort" +) + +// Action is a named command bound to a key in the Picker and run on the +// selected Project. +type Action struct { + // Name identifies the Action in config.toml ([actions.]). + Name string + + // Key is the Picker key that runs the Action, in the canonical form of + // a Bubble Tea key press ("ctrl+v"), or "" when the Action is unbound. + Key string + + // Run is the shell command, with "{path}" standing for the Project's + // shell-quoted absolute path. It may be empty only for an Action that + // Jumps. + Run string + + // Jump makes the Action Jump to the Project once Run exits. + Jump bool + + // Detach starts Run in its own session without waiting, and leaves the + // Picker open. + Detach bool +} + +// Override is the part of an Action a [actions.] table sets. A nil +// field is one the table left out, so a built-in keeps its own value. +type Override struct { + Key *string `toml:"key"` + Run *string `toml:"run"` + Jump *bool `toml:"jump"` + Detach *bool `toml:"detach"` +} + +// builtins are the Actions cdd ships, in the order the Picker lists them. +// A built-in is registered by adding it here; a user's [actions.] +// table with the same name overrides it field by field. +var builtins = []Action{} + +// Builtins returns a copy of the built-in Actions. +func Builtins() []Action { + return slices.Clone(builtins) +} + +// Error is an invalid field of one Action. Field is the config key it +// concerns ("key", "run"), so a caller can point at its line. +type Error struct { + Name string + Field string + Err error +} + +func (e *Error) Error() string { + return fmt.Sprintf("actions.%s: %s: %v", e.Name, e.Field, e.Err) +} + +func (e *Error) Unwrap() error { return e.Err } + +// Merge applies user's overrides onto the built-in Actions and returns the +// result: the built-ins in their own order, then the user's other Actions +// sorted by name. It fails on the first invalid Action with an *Error. +// +// vim says whether the vim key map is on, the only one where a plain +// printable key may be bound. +func Merge(user map[string]Override, vim bool) ([]Action, error) { + out := Builtins() + index := make(map[string]int, len(out)) + for i, a := range out { + index[a.Name] = i + } + + var added []string + for name := range user { + if _, ok := index[name]; !ok { + added = append(added, name) + } + } + sort.Strings(added) + for _, name := range added { + index[name] = len(out) + out = append(out, Action{Name: name}) + } + + for name, o := range user { + a := &out[index[name]] + if o.Key != nil { + a.Key = *o.Key + } + if o.Run != nil { + a.Run = *o.Run + } + if o.Jump != nil { + a.Jump = *o.Jump + } + if o.Detach != nil { + a.Detach = *o.Detach + } + } + + owner := make(map[string]string, len(out)) + for i := range out { + a := &out[i] + if a.Run == "" && !a.Jump { + return nil, &Error{a.Name, "run", errors.New("a command is required unless jump is true")} + } + key, err := NormalizeKey(a.Key, vim) + if err != nil { + return nil, &Error{a.Name, "key", err} + } + a.Key = key + if key == "" { + continue + } + if prev, ok := owner[key]; ok { + return nil, &Error{a.Name, "key", fmt.Errorf("%q is already bound to %q", key, prev)} + } + owner[key] = a.Name + } + return out, nil +} diff --git a/internal/action/action_test.go b/internal/action/action_test.go new file mode 100644 index 0000000..377ec90 --- /dev/null +++ b/internal/action/action_test.go @@ -0,0 +1,131 @@ +package action + +import ( + "errors" + "slices" + "testing" +) + +func str(s string) *string { return &s } +func flag(b bool) *bool { return &b } + +// withBuiltins registers fake built-ins for one test. +func withBuiltins(t *testing.T, as ...Action) { + t.Helper() + old := builtins + builtins = as + t.Cleanup(func() { builtins = old }) +} + +func names(as []Action) []string { + out := make([]string, len(as)) + for i, a := range as { + out[i] = a.Name + } + return out +} + +func TestMerge_UserActionsFollowBuiltinsSortedByName(t *testing.T) { + withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}", Detach: true}) + + got, err := Merge(map[string]Override{ + "zed": {Run: str("zed {path}")}, + "code": {Key: str("ctrl+v"), Run: str("code {path}"), Detach: flag(true)}, + }, false) + if err != nil { + t.Fatalf("Merge: %v", err) + } + + if want := []string{"files", "code", "zed"}; !slices.Equal(names(got), want) { + t.Fatalf("names = %v, want %v", names(got), want) + } + code := got[1] + if code.Key != "ctrl+v" || code.Run != "code {path}" || !code.Detach || code.Jump { + t.Errorf("code = %+v", code) + } +} + +func TestMerge_OverridesABuiltinFieldByField(t *testing.T) { + withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}", Detach: true}) + + got, err := Merge(map[string]Override{"files": {Key: str("ctrl+f")}}, false) + if err != nil { + t.Fatalf("Merge: %v", err) + } + + want := Action{Name: "files", Key: "ctrl+f", Run: "xdg-open {path}", Detach: true} + if len(got) != 1 || got[0] != want { + t.Errorf("got %+v, want [%+v]", got, want) + } +} + +func TestMerge_EmptyKeyUnbindsABuiltin(t *testing.T) { + withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}"}) + + got, err := Merge(map[string]Override{"files": {Key: str("")}}, false) + if err != nil { + t.Fatalf("Merge: %v", err) + } + if got[0].Key != "" { + t.Errorf("Key = %q, want it unbound", got[0].Key) + } +} + +func TestMerge_Errors(t *testing.T) { + tests := []struct { + name string + user map[string]Override + vim bool + wantName string + wantField string + }{ + {"missing run", map[string]Override{"a": {Key: str("ctrl+a")}}, false, "a", "run"}, + {"unknown key", map[string]Override{"a": {Key: str("ctrl+"), Run: str("x")}}, false, "a", "key"}, + {"esc", map[string]Override{"a": {Key: str("esc"), Run: str("x")}}, true, "a", "key"}, + {"ctrl+c", map[string]Override{"a": {Key: str("ctrl+c"), Run: str("x")}}, false, "a", "key"}, + {"printable without vim", map[string]Override{"a": {Key: str("?"), Run: str("x")}}, false, "a", "key"}, + { + "two Actions on a key", + map[string]Override{"a": {Key: str("ctrl+x"), Run: str("x")}, "b": {Key: str("ctrl+x"), Run: str("y")}}, + false, "b", "key", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withBuiltins(t) + _, err := Merge(tt.user, tt.vim) + var ae *Error + if !errors.As(err, &ae) { + t.Fatalf("err = %v, want *Error", err) + } + if ae.Name != tt.wantName || ae.Field != tt.wantField { + t.Errorf("Error = %s/%s, want %s/%s", ae.Name, ae.Field, tt.wantName, tt.wantField) + } + }) + } +} + +func TestMerge_ClashWithABuiltinNamesTheUserAction(t *testing.T) { + withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}"}) + + _, err := Merge(map[string]Override{"code": {Key: str("ctrl+o"), Run: str("code")}}, false) + var ae *Error + if !errors.As(err, &ae) || ae.Name != "code" { + t.Fatalf("err = %v, want an *Error for code", err) + } +} + +func TestMerge_PrintableKeyAllowedWithVim(t *testing.T) { + withBuiltins(t) + got, err := Merge(map[string]Override{"a": {Key: str("?"), Run: str("x")}}, true) + if err != nil || got[0].Key != "?" { + t.Fatalf("got %+v, %v", got, err) + } +} + +func TestMerge_JumpNeedsNoRun(t *testing.T) { + withBuiltins(t, Action{Name: "jump", Key: "enter", Jump: true}) + if _, err := Merge(nil, false); err != nil { + t.Fatalf("Merge: %v", err) + } +} diff --git a/internal/action/key.go b/internal/action/key.go new file mode 100644 index 0000000..c8c5de7 --- /dev/null +++ b/internal/action/key.go @@ -0,0 +1,94 @@ +package action + +import ( + "fmt" + "slices" + "strconv" + "strings" + "unicode/utf8" +) + +// namedKeys are the multi-character key names the Picker recognises, as a +// Bubble Tea key press spells them. +var namedKeys = []string{ + "enter", "tab", "backspace", "delete", "insert", "esc", "space", + "up", "down", "left", "right", "home", "end", "pgup", "pgdown", +} + +// modifiers in the order a Bubble Tea key press spells them. +var modifiers = []string{"ctrl", "alt", "shift"} + +// NormalizeKey checks that key names a key the Picker recognises and can +// bind, and returns it in the canonical spelling of a Bubble Tea key press +// (modifiers ordered ctrl, alt, shift). The empty key, an unbound Action, +// is valid. +// +// esc and ctrl+c can never be bound. A plain printable key would steal +// typing, so it is valid only when vim is true, where it applies in list +// focus. +func NormalizeKey(key string, vim bool) (string, error) { + if key == "" { + return "", nil + } + + parts, base := strings.Split(key, "+"), "" + switch { + case key == "+": + parts, base = nil, "+" + case strings.HasSuffix(key, "++"): // "alt++" names the plus key itself + parts, base = strings.Split(strings.TrimSuffix(key, "++"), "+"), "+" + default: + parts, base = parts[:len(parts)-1], parts[len(parts)-1] + } + + var mods []string + for _, m := range parts { + if !slices.Contains(modifiers, m) || slices.Contains(mods, m) { + return "", fmt.Errorf("unknown key %q", key) + } + mods = append(mods, m) + } + slices.SortFunc(mods, func(a, b string) int { + return slices.Index(modifiers, a) - slices.Index(modifiers, b) + }) + + if err := checkBase(key, base, mods); err != nil { + return "", err + } + + canon := strings.Join(append(mods, base), "+") + if canon == "esc" || canon == "ctrl+c" { + return "", fmt.Errorf("%q can never be bound", canon) + } + if IsPrintable(canon) && !vim { + return "", fmt.Errorf("%q would steal typing; plain keys need keys.vim = true", canon) + } + return canon, nil +} + +// checkBase checks the last part of key, base, against its modifiers. +func checkBase(key, base string, mods []string) error { + switch { + case slices.Contains(namedKeys, base), isFunctionKey(base): + return nil + case utf8.RuneCountInString(base) != 1: + return fmt.Errorf("unknown key %q", key) + case slices.Contains(mods, "ctrl") && (base < "a" || base > "z"): + return fmt.Errorf("ctrl takes a letter, got %q", key) + case slices.Contains(mods, "shift"): + return fmt.Errorf("shift takes a named key, got %q", key) + } + return nil +} + +// isFunctionKey reports whether s is f1 to f20. +func isFunctionKey(s string) bool { + n, err := strconv.Atoi(strings.TrimPrefix(s, "f")) + return err == nil && strings.HasPrefix(s, "f") && n >= 1 && n <= 20 +} + +// IsPrintable reports whether key, in canonical form, is a plain typed +// character: one the filter would otherwise take as text. +func IsPrintable(key string) bool { + return key == "space" || utf8.RuneCountInString(key) == 1 +} diff --git a/internal/action/key_test.go b/internal/action/key_test.go new file mode 100644 index 0000000..573cb80 --- /dev/null +++ b/internal/action/key_test.go @@ -0,0 +1,51 @@ +package action_test + +import ( + "testing" + + "github.com/kryft-dev/cdd/internal/action" +) + +func TestNormalizeKey(t *testing.T) { + tests := []struct { + key string + vim bool + want string // "" with wantErr false is an unbound key + err bool + }{ + {key: ""}, + {key: "ctrl+v", want: "ctrl+v"}, + {key: "alt+x", want: "alt+x"}, + {key: "alt+ctrl+x", want: "ctrl+alt+x"}, + {key: "enter", want: "enter"}, + {key: "f1", want: "f1"}, + {key: "f20", want: "f20"}, + {key: "shift+tab", want: "shift+tab"}, + {key: "ctrl++", err: true}, + {key: "alt++", want: "alt++"}, + {key: "ctrl+", err: true}, + {key: "f21", err: true}, + {key: "f", err: true}, + {key: "ctrl+1", err: true}, + {key: "shift+a", err: true}, + {key: "ctrl+ctrl+a", err: true}, + {key: "hyper+a", err: true}, + {key: "nonsense", err: true}, + {key: "esc", vim: true, err: true}, + {key: "ctrl+c", vim: true, err: true}, + {key: "?", err: true}, + {key: "?", vim: true, want: "?"}, + {key: "G", vim: true, want: "G"}, + {key: "space", err: true}, + } + for _, tt := range tests { + got, err := action.NormalizeKey(tt.key, tt.vim) + if (err != nil) != tt.err { + t.Errorf("NormalizeKey(%q, vim=%v) error = %v, want error %v", tt.key, tt.vim, err, tt.err) + continue + } + if got != tt.want { + t.Errorf("NormalizeKey(%q, vim=%v) = %q, want %q", tt.key, tt.vim, got, tt.want) + } + } +} diff --git a/internal/action/run.go b/internal/action/run.go new file mode 100644 index 0000000..959bfe8 --- /dev/null +++ b/internal/action/run.go @@ -0,0 +1,84 @@ +package action + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + "syscall" +) + +// Runner starts the commands of Actions. The Picker and the Resolve flow +// take one, so tests inject a fake and never launch a real program. +type Runner interface { + // Start launches a's command on the Project at path and returns once it + // has started, without waiting, with its output discarded. It is how a + // detached Action runs while the Picker stays open. + Start(a Action, path string) error + + // Run launches a's command on the Project at path with the terminal for + // stdin, stdout and stderr, waits, and returns its exit status. It is + // how any other Action runs, after the Picker has quit. The error is + // for a command that could not run at all, not one that exited non-zero. + Run(a Action, path string) (int, error) +} + +// ExecRunner is the Runner that runs commands as "sh -c". +type ExecRunner struct { + // TTY is the terminal file Run connects the command to. The empty value + // is /dev/tty, never the binary's own stdout, which the Wrapper + // captures as the Jump target. + TTY string +} + +// Command builds the "sh -c" command for a on the Project at path: "{path}" +// in Run is replaced by the shell-quoted path, the working directory is +// the Project, and CDD_PATH holds the path. +func Command(a Action, path string) *exec.Cmd { + cmd := exec.Command("sh", "-c", strings.ReplaceAll(a.Run, "{path}", shellQuote(path))) + cmd.Dir = path + cmd.Env = append(os.Environ(), "CDD_PATH="+path) + return cmd +} + +// shellQuote wraps s in single quotes for sh, so spaces and metacharacters +// in it stay literal. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// Start implements Runner. The command gets a session of its own, so it +// outlives the Picker, and /dev/null for stdio. +func (ExecRunner) Start(a Action, path string) error { + cmd := Command(a, path) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return err + } + go func() { _ = cmd.Wait() }() // reap it + return nil +} + +// Run implements Runner. +func (r ExecRunner) Run(a Action, path string) (int, error) { + name := r.TTY + if name == "" { + name = "/dev/tty" + } + tty, err := os.OpenFile(name, os.O_RDWR, 0) + if err != nil { + return 0, fmt.Errorf("open terminal: %w", err) + } + defer tty.Close() + + cmd := Command(a, path) + cmd.Stdin, cmd.Stdout, cmd.Stderr = tty, tty, tty + err = cmd.Run() + + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode(), nil + } + return 0, err +} diff --git a/internal/action/run_test.go b/internal/action/run_test.go new file mode 100644 index 0000000..3032ab4 --- /dev/null +++ b/internal/action/run_test.go @@ -0,0 +1,92 @@ +package action_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kryft-dev/cdd/internal/action" +) + +// spacedDir makes a Project directory whose path has a space and a quote. +func spacedDir(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "my proj's dir") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + return dir +} + +// fakeTTY creates an empty file for ExecRunner.Run to stand in for +// /dev/tty, and returns its path. +func fakeTTY(t *testing.T) string { + t.Helper() + tty := filepath.Join(t.TempDir(), "tty") + if err := os.WriteFile(tty, nil, 0o600); err != nil { + t.Fatal(err) + } + return tty +} + +func TestExecRunner_RunQuotesPathAndSetsEnvAndCwd(t *testing.T) { + dir := spacedDir(t) + tty := fakeTTY(t) + r := action.ExecRunner{TTY: tty} + + a := action.Action{Run: `printf '%s|%s|%s' {path} "$CDD_PATH" "$PWD"`} + code, err := r.Run(a, dir) + if err != nil || code != 0 { + t.Fatalf("Run = %d, %v", code, err) + } + + out, _ := os.ReadFile(tty) + if want := strings.Repeat(dir+"|", 2) + dir; string(out) != want { + t.Errorf("output = %q, want %q", out, want) + } +} + +func TestExecRunner_RunReturnsExitStatus(t *testing.T) { + r := action.ExecRunner{TTY: fakeTTY(t)} + + code, err := r.Run(action.Action{Run: "exit 3"}, t.TempDir()) + if err != nil || code != 3 { + t.Errorf("Run = %d, %v, want 3, nil", code, err) + } +} + +func TestExecRunner_RunFailsWithoutATerminal(t *testing.T) { + r := action.ExecRunner{TTY: filepath.Join(t.TempDir(), "missing", "tty")} + + if _, err := r.Run(action.Action{Run: "true"}, t.TempDir()); err == nil { + t.Error("Run: want an error when the terminal cannot be opened") + } +} + +func TestExecRunner_StartRunsWithoutWaiting(t *testing.T) { + dir := spacedDir(t) + + a := action.Action{Run: `printf '%s' {path} > "$CDD_PATH/out"; printf noise`, Detach: true} + if err := (action.ExecRunner{}).Start(a, dir); err != nil { + t.Fatalf("Start: %v", err) + } + + var out []byte + for i := 0; i < 200 && string(out) != dir; i++ { + time.Sleep(10 * time.Millisecond) + out, _ = os.ReadFile(filepath.Join(dir, "out")) + } + if string(out) != dir { + t.Errorf("out = %q, want %q", out, dir) + } +} + +func TestExecRunner_StartReportsAFailureToStart(t *testing.T) { + missing := filepath.Join(t.TempDir(), "gone") + + if err := (action.ExecRunner{}).Start(action.Action{Run: "true"}, missing); err == nil { + t.Error("Start: want an error for a Project directory that does not exist") + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 27d63f1..78db3b0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -22,7 +22,8 @@ func Execute() int { // Run parses args against the cdd command tree, writing to stdout and // stderr, and returns the process exit code: 0 on success, 130 silently on -// a cancelled Picker (jump.ErrCancelled), 1 with a stderr message on any +// a cancelled Picker (jump.ErrCancelled), the exit status of an Action's +// command that failed (jump.ExitError), 1 with a stderr message on any // other error, 2 with a stderr message on a usage error. // // Run is exported, rather than Execute alone, so tests can drive the CLI @@ -43,6 +44,10 @@ func Run(args []string, stdout, stderr io.Writer) int { if errors.Is(err, jump.ErrCancelled) { return 130 } + var exit *jump.ExitError + if errors.As(err, &exit) { + return exit.Code + } _, _ = fmt.Fprintln(stderr, err) if isUsageError(err) { diff --git a/internal/cli/pick.go b/internal/cli/pick.go index 91cf55c..be081cc 100644 --- a/internal/cli/pick.go +++ b/internal/cli/pick.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" + "github.com/kryft-dev/cdd/internal/action" "github.com/kryft-dev/cdd/internal/config" "github.com/kryft-dev/cdd/internal/history" "github.com/kryft-dev/cdd/internal/jump" @@ -29,9 +30,12 @@ func newPickCmd() *cobra.Command { // pickRunE implements both "cdd pick" and the bare "cdd" root command: // load config, open History at its default path, resolve a Project via // jump.Resolve and picker.Run, and print the chosen absolute path plus a newline to stdout and nothing else. +// An Action that does not Jump prints nothing, so the Wrapper has nothing +// to cd to. // // A cancelled Picker returns jump.ErrCancelled, which Run reports as exit -// 130 with no message. Any other error is reported by Run as exit 1 with +// 130 with no message, and an Action's non-zero exit status is passed on +// through jump.ExitError. Any other error is reported by Run as exit 1 with // the error's message on stderr. func pickRunE(cmd *cobra.Command, _ []string) error { cfg, err := config.Load() @@ -49,11 +53,15 @@ func pickRunE(cmd *cobra.Command, _ []string) error { return err } - abs, err := jump.Resolve(cmd.Context(), cfg, hist, picker.Run) + abs, err := jump.Resolve(cmd.Context(), cfg, hist, picker.Run, action.ExecRunner{}) if err != nil { return err } + if abs == "" { + return nil // an Action ran and does not Jump + } + out := cmd.OutOrStdout() if _, err := fmt.Fprintln(out, abs); err != nil { return err diff --git a/internal/config/actions.go b/internal/config/actions.go new file mode 100644 index 0000000..1c3fce6 --- /dev/null +++ b/internal/config/actions.go @@ -0,0 +1,58 @@ +package config + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/kryft-dev/cdd/internal/action" +) + +// resolveActions merges the [actions.*] tables onto the built-in Actions +// into c.ResolvedActions. data is config.toml's text, used only to name the +// line an invalid Action sits on. +func (c *Config) resolveActions(path string, data []byte) error { + resolved, err := action.Merge(c.Actions, c.Keys.Vim) + if err != nil { + var ae *action.Error + if errors.As(err, &ae) { + if line := actionLine(data, ae.Name, ae.Field); line > 0 { + return fmt.Errorf("config: %s: line %d: %w", path, line, err) + } + } + return fmt.Errorf("config: %s: %w", path, err) + } + c.ResolvedActions = resolved + return nil +} + +var ( + tableHeader = regexp.MustCompile(`^\s*\[\s*([^\]]*?)\s*\]\s*(#.*)?$`) + keyLine = regexp.MustCompile(`^\s*([A-Za-z0-9_-]+)\s*=`) +) + +// actionLine returns the 1-based line of field in the [actions.] +// table of data, the table's header when it does not set field (an +// override can leave a built-in's key alone yet still clash), or 0 when +// there is no such table. +func actionLine(data []byte, name, field string) int { + want := "actions." + name + quoted := "actions." + strconv.Quote(name) + + header, inTable := 0, false + for i, line := range strings.Split(string(data), "\n") { + if m := tableHeader.FindStringSubmatch(line); m != nil { + inTable = m[1] == want || m[1] == quoted + if inTable && header == 0 { + header = i + 1 + } + continue + } + if m := keyLine.FindStringSubmatch(line); inTable && m != nil && m[1] == field { + return i + 1 + } + } + return header +} diff --git a/internal/config/actions_test.go b/internal/config/actions_test.go new file mode 100644 index 0000000..2e546c3 --- /dev/null +++ b/internal/config/actions_test.go @@ -0,0 +1,86 @@ +package config_test + +import ( + "strings" + "testing" + + "github.com/kryft-dev/cdd/internal/action" + "github.com/kryft-dev/cdd/internal/config" +) + +func TestLoadFromActions(t *testing.T) { + cfg, err := config.LoadFrom(writeConfig(t, ` +[actions.code] +key = "ctrl+v" +run = "code {path}" +detach = true + +[actions.lazygit] +key = "alt+ctrl+l" +run = "lazygit" +jump = true +`)) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + + want := []action.Action{ + {Name: "code", Key: "ctrl+v", Run: "code {path}", Detach: true}, + {Name: "lazygit", Key: "ctrl+alt+l", Run: "lazygit", Jump: true}, + } + got := cfg.ResolvedActions[len(cfg.ResolvedActions)-2:] + for i := range want { + if got[i] != want[i] { + t.Errorf("action %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestLoadFromMissingFileResolvesBuiltins(t *testing.T) { + cfg, err := config.LoadFrom(writeConfig(t, "")) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if len(cfg.ResolvedActions) != len(action.Builtins()) { + t.Errorf("ResolvedActions = %v, want the built-ins", cfg.ResolvedActions) + } +} + +func TestLoadFromActionsRejected(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {"unknown key name", "[actions.a]\nrun = \"x\"\nkey = \"ctrl+\"\n", "line 3"}, + {"esc", "[actions.a]\nrun = \"x\"\nkey = \"esc\"\n", "line 3"}, + {"ctrl+c", "[actions.a]\nkey = \"ctrl+c\"\nrun = \"x\"\n", "line 2"}, + {"printable key", "[actions.a]\nrun = \"x\"\nkey = \"a\"\n", "keys.vim"}, + {"missing run", "\n[actions.a]\nkey = \"ctrl+a\"\n", "line 2"}, + { + "two Actions on a key", + "[actions.a]\nrun = \"x\"\nkey = \"ctrl+a\"\n[actions.b]\nrun = \"y\"\nkey = \"ctrl+a\"\n", + "line 6", + }, + {"unknown field", "[actions.a]\nrun = \"x\"\nbogus = 1\n", "bogus"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := config.LoadFrom(writeConfig(t, tt.body)) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("LoadFrom error = %v, want it to mention %q", err, tt.want) + } + }) + } +} + +func TestLoadFromPrintableActionKeyAllowedWithVim(t *testing.T) { + cfg, err := config.LoadFrom(writeConfig(t, "[keys]\nvim = true\n[actions.help]\nrun = \"x\"\nkey = \"?\"\n")) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + last := cfg.ResolvedActions[len(cfg.ResolvedActions)-1] + if last.Name != "help" || last.Key != "?" { + t.Errorf("last action = %+v", last) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 982030c..209d4da 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,8 @@ import ( "strings" toml "github.com/pelletier/go-toml/v2" + + "github.com/kryft-dev/cdd/internal/action" ) // Config is cdd's configuration, decoded from config.toml. @@ -36,6 +38,14 @@ type Config struct { // Picker configures the Picker's appearance. Picker Picker `toml:"picker"` + + // Actions holds the [actions.] tables: new Actions, and + // overrides of built-in ones by name. + Actions map[string]action.Override `toml:"actions"` + + // ResolvedActions is every Action the Picker binds: the built-ins with + // Actions applied on top. LoadFrom fills it in. + ResolvedActions []action.Action `toml:"-"` } // History configures cdd's History of Visits. @@ -99,7 +109,8 @@ func LoadFrom(path string) (Config, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { - return cfg, nil + err := cfg.resolveActions(path, nil) + return cfg, err } return Config{}, fmt.Errorf("config: read %s: %w", path, err) } @@ -113,6 +124,9 @@ func LoadFrom(path string) (Config, error) { if err := cfg.validate(path); err != nil { return Config{}, err } + if err := cfg.resolveActions(path, data); err != nil { + return Config{}, err + } return cfg, nil } diff --git a/internal/jump/action_test.go b/internal/jump/action_test.go new file mode 100644 index 0000000..382a2b7 --- /dev/null +++ b/internal/jump/action_test.go @@ -0,0 +1,166 @@ +package jump_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/kryft-dev/cdd/internal/action" + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/jump" + "github.com/kryft-dev/cdd/internal/picker" +) + +// fakeRunner stands in for the Runner, so no test launches a program. It +// records what ran, and answers Run with code and runErr, Start with +// startErr. +type fakeRunner struct { + ran []string // "name path" + code int + runErr error + startErr error +} + +func (f *fakeRunner) Start(a action.Action, path string) error { + f.ran = append(f.ran, "start "+a.Name+" "+path) + return f.startErr +} + +func (f *fakeRunner) Run(a action.Action, path string) (int, error) { + f.ran = append(f.ran, "run "+a.Name+" "+path) + return f.code, f.runErr +} + +// pickWith returns a PickFunc that chooses path with a, and leaves the +// Options it was given in opts. +func pickWith(a action.Action, path string, opts *picker.Options) jump.PickFunc { + return func(_ []picker.Row, _ picker.StatusFunc, o picker.Options) (picker.Choice, bool, error) { + *opts = o + return picker.Choice{Row: picker.Row{Project: picker.Project{Path: path}}, Action: &a}, true, nil + } +} + +func visitCount(t *testing.T, hist *history.History, path string) int { + t.Helper() + n, err := hist.Count(path) + if err != nil { + t.Fatalf("Count: %v", err) + } + return n +} + +func TestResolve_AttachedActionRunsOnTheTerminalAndRecordsAVisit(t *testing.T) { + hist := newHistory(t) + cfg, root := mkProjects(t, hist, "tools/cdd") + path := filepath.Join(root, "tools", "cdd") + before := visitCount(t, hist, path) + + run := &fakeRunner{} + var opts picker.Options + got, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "lazygit", Run: "lazygit"}, path, &opts), run) + + if err != nil || got != "" { + t.Errorf("Resolve = %q, %v, want no path and no error", got, err) + } + if len(run.ran) != 1 || run.ran[0] != "run lazygit "+path { + t.Errorf("ran = %v, want lazygit on %s", run.ran, path) + } + if n := visitCount(t, hist, path); n != before+1 { + t.Errorf("Visits = %d, want %d", n, before+1) + } +} + +func TestResolve_JumpActionReturnsThePathAfterTheCommand(t *testing.T) { + hist := newHistory(t) + cfg, root := mkProjects(t, hist, "tools/cdd") + path := filepath.Join(root, "tools", "cdd") + + run := &fakeRunner{} + var opts picker.Options + got, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "fmt", Run: "gofmt", Jump: true}, path, &opts), run) + + if err != nil || got != path { + t.Errorf("Resolve = %q, %v, want %q", got, err, path) + } + if len(run.ran) != 1 { + t.Errorf("ran = %v, want the command to have run", run.ran) + } +} + +func TestResolve_ActionWithoutACommandOnlyJumps(t *testing.T) { + hist := newHistory(t) + cfg, root := mkProjects(t, hist, "tools/cdd") + path := filepath.Join(root, "tools", "cdd") + + run := &fakeRunner{} + var opts picker.Options + got, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), run) + + if err != nil || got != path || len(run.ran) != 0 { + t.Errorf("Resolve = %q, %v, ran %v, want the path and no command", got, err, run.ran) + } +} + +func TestResolve_FailingCommandReturnsItsExitStatusAndNoPath(t *testing.T) { + hist := newHistory(t) + cfg, root := mkProjects(t, hist, "tools/cdd") + path := filepath.Join(root, "tools", "cdd") + + var opts picker.Options + got, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "x", Run: "x", Jump: true}, path, &opts), &fakeRunner{code: 3}) + + var exit *jump.ExitError + if !errors.As(err, &exit) || exit.Code != 3 || got != "" { + t.Errorf("Resolve = %q, %v, want ExitError{3} and no path", got, err) + } +} + +func TestResolve_CommandThatCannotRunIsAnError(t *testing.T) { + hist := newHistory(t) + cfg, root := mkProjects(t, hist, "tools/cdd") + path := filepath.Join(root, "tools", "cdd") + + var opts picker.Options + _, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "x", Run: "x"}, path, &opts), &fakeRunner{runErr: errors.New("no tty")}) + + var exit *jump.ExitError + if err == nil || errors.As(err, &exit) { + t.Errorf("Resolve error = %v, want a plain error", err) + } +} + +func TestResolve_PickerGetsTheResolvedActionsAndARecordingRunner(t *testing.T) { + hist := newHistory(t) + cfg, root := mkProjects(t, hist, "tools/cdd") + path := filepath.Join(root, "tools", "cdd") + cfg.ResolvedActions = []action.Action{{Name: "code", Key: "ctrl+v", Run: "code", Detach: true}} + + run := &fakeRunner{} + var opts picker.Options + if _, err := jump.Resolve(context.Background(), cfg, hist, pickWith(action.Action{Name: "jump", Jump: true}, path, &opts), run); err != nil { + t.Fatalf("Resolve: %v", err) + } + if len(opts.Actions) != 1 || opts.Actions[0].Name != "code" { + t.Errorf("Options.Actions = %v, want the config's resolved Actions", opts.Actions) + } + + // What the Picker does when a detached Action key is pressed. + before := visitCount(t, hist, path) + if err := opts.Runner.Start(opts.Actions[0], path); err != nil { + t.Fatalf("Start: %v", err) + } + if n := visitCount(t, hist, path); n != before+1 { + t.Errorf("Visits = %d, want %d after a detached Action", n, before+1) + } + + // A detached Action that fails to start is no Visit. + run.startErr = errors.New("boom") + before = visitCount(t, hist, path) + if err := opts.Runner.Start(opts.Actions[0], path); err == nil { + t.Fatal("Start: want the runner's error") + } + if n := visitCount(t, hist, path); n != before { + t.Errorf("Visits = %d, want %d after a failed start", n, before) + } +} diff --git a/internal/jump/jump.go b/internal/jump/jump.go index 0eb5a20..551457e 100644 --- a/internal/jump/jump.go +++ b/internal/jump/jump.go @@ -6,6 +6,7 @@ import ( "fmt" "os" + "github.com/kryft-dev/cdd/internal/action" "github.com/kryft-dev/cdd/internal/config" "github.com/kryft-dev/cdd/internal/git" "github.com/kryft-dev/cdd/internal/history" @@ -17,19 +18,32 @@ import ( // Ctrl-C, or q on an empty filter). var ErrCancelled = errors.New("jump: cancelled") -// PickFunc runs the Picker over rows and returns the chosen Row, mirroring +// ExitError is returned by Resolve when an Action's command exits non-zero. +// The command has already said what went wrong on the terminal, so the CLI +// exits with Code and prints nothing. +type ExitError struct{ Code int } + +func (e *ExitError) Error() string { return fmt.Sprintf("jump: action exited with status %d", e.Code) } + +// PickFunc runs the Picker over rows and returns the Choice, mirroring // picker.Run's signature so tests can inject a fake Picker; production // passes picker.Run itself. -type PickFunc func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) +type PickFunc func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Choice, bool, error) // Resolve runs the pick flow that turns History into the absolute path of // the Project to Jump to: read its latest Visits, drop the Stale ones, run -// the Picker (via pick) with an empty Query, confirm the chosen directory -// still exists, Record the Visit, and return the path. +// the Picker (via pick), confirm the chosen directory still exists, Record +// the Visit, and return the path. +// +// When the user ran an Action that does not detach, Resolve also runs its +// command on the terminal through run, once the Picker has quit. It +// returns the path only if the Action Jumps, "" if not, and an *ExitError +// if the command exited non-zero. A detached Action runs inside the Picker +// through run too, Recording its Visit. // // A cancelled Picker yields ErrCancelled. A Visit that fails to Record // only prints a warning to stderr; Resolve still returns the path. -func Resolve(ctx context.Context, cfg config.Config, hist *history.History, pick PickFunc) (string, error) { +func Resolve(ctx context.Context, cfg config.Config, hist *history.History, pick PickFunc, run action.Runner) (string, error) { latest, err := hist.Latest() if err != nil { return "", fmt.Errorf("jump: %w", err) @@ -40,20 +54,57 @@ func Resolve(ctx context.Context, cfg config.Config, hist *history.History, pick return "", fmt.Errorf("jump: %w", err) } - path, err := choose(cfg, live(latest), counts, pick) + choice, err := choose(cfg, live(latest), counts, pick, recorder{run, hist}) if err != nil { return "", err } + path := choice.Row.Project.Path if _, err := os.Stat(path); err != nil { return "", fmt.Errorf("jump: %q no longer exists: %w", path, err) } + record(hist, path) + + a := choice.Action + if a == nil { + return path, nil + } + if a.Run != "" { + code, err := run.Run(*a, path) + if err != nil { + return "", fmt.Errorf("jump: run %s: %w", a.Name, err) + } + if code != 0 { + return "", &ExitError{code} + } + } + if a.Jump { + return path, nil + } + return "", nil +} + +// record Records a Visit for path, warning on stderr when it cannot. +func record(hist *history.History, path string) { if err := hist.Record(path); err != nil { fmt.Fprintf(os.Stderr, "cdd: warning: recording Visit for %q: %v\n", path, err) } +} + +// recorder is the Runner the Picker gets: it Records a Visit for each +// detached Action that starts. +type recorder struct { + action.Runner + hist *history.History +} - return path, nil +func (r recorder) Start(a action.Action, path string) error { + if err := r.Runner.Start(a, path); err != nil { + return err + } + record(r.hist, path) + return nil } // live drops each Stale Visit from latest: one whose Project no longer @@ -68,9 +119,9 @@ func live(latest []history.Visit) []history.Visit { return out } -// choose runs the Picker over latest and returns the absolute path of the -// Project it chose. -func choose(cfg config.Config, latest []history.Visit, counts map[string]int, pick PickFunc) (string, error) { +// choose runs the Picker over latest, starting its detached Actions with +// run, and returns what it chose. +func choose(cfg config.Config, latest []history.Visit, counts map[string]int, pick PickFunc, run action.Runner) (picker.Choice, error) { home, _ := os.UserHomeDir() rows := toRows(latest, counts, home) status := func(c context.Context, dir string) git.Status { @@ -78,12 +129,18 @@ func choose(cfg config.Config, latest []history.Visit, counts map[string]int, pi return s } - row, ok, err := pick(rows, status, picker.Options{Vim: cfg.Keys.Vim, Layout: picker.Layout(cfg.Picker.Layout)}) + opts := picker.Options{ + Vim: cfg.Keys.Vim, + Layout: picker.Layout(cfg.Picker.Layout), + Actions: cfg.ResolvedActions, + Runner: run, + } + choice, ok, err := pick(rows, status, opts) if err != nil { - return "", fmt.Errorf("jump: %w", err) + return picker.Choice{}, fmt.Errorf("jump: %w", err) } if !ok { - return "", ErrCancelled + return picker.Choice{}, ErrCancelled } - return row.Project.Path, nil + return choice, nil } diff --git a/internal/jump/resolve_test.go b/internal/jump/resolve_test.go index 177934e..c93d7ac 100644 --- a/internal/jump/resolve_test.go +++ b/internal/jump/resolve_test.go @@ -37,12 +37,12 @@ func mkProjects(t *testing.T, hist *history.History, rels ...string) (config.Con // capturePick returns a PickFunc that stores the rows and Options it was // run with and chooses the row at path, or cancels when path is "". func capturePick(rows *[]picker.Row, opts *picker.Options, path string) jump.PickFunc { - return func(r []picker.Row, _ picker.StatusFunc, o picker.Options) (picker.Row, bool, error) { + return func(r []picker.Row, _ picker.StatusFunc, o picker.Options) (picker.Choice, bool, error) { *rows, *opts = r, o if path == "" { - return picker.Row{}, false, nil + return picker.Choice{}, false, nil } - return picker.Row{Project: picker.Project{Path: path}}, true, nil + return picker.Choice{Row: picker.Row{Project: picker.Project{Path: path}}}, true, nil } } @@ -68,7 +68,7 @@ func TestResolve_AlwaysOpensPickerWithEmptyQuery(t *testing.T) { var rows []picker.Row var opts picker.Options - got, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, want)) + got, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, want), &fakeRunner{}) if err != nil { t.Fatalf("Resolve: unexpected error: %v", err) } @@ -100,7 +100,7 @@ func TestResolve_RowsComeFromHistoryNewestFirst(t *testing.T) { var rows []picker.Row var opts picker.Options - _, _ = jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, "")) + _, _ = jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, ""), &fakeRunner{}) var got []string for _, r := range rows { @@ -121,7 +121,7 @@ func TestResolve_StaleVisitGivesNoRow(t *testing.T) { var rows []picker.Row var opts picker.Options - _, _ = jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, "")) + _, _ = jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, ""), &fakeRunner{}) if len(rows) != 1 { t.Errorf("Picker got %d rows, want 1 (Stale Visit dropped)", len(rows)) } @@ -137,7 +137,7 @@ func TestResolve_ForwardsPickerOptions(t *testing.T) { var rows []picker.Row var got picker.Options - if _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &got, filepath.Join(root, "tools", "cdd"))); err != nil { + if _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &got, filepath.Join(root, "tools", "cdd")), &fakeRunner{}); err != nil { t.Fatalf("Resolve: unexpected error: %v", err) } @@ -155,7 +155,7 @@ func TestResolve_CancelReturnsErrCancelled(t *testing.T) { var rows []picker.Row var opts picker.Options - _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, "")) + _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, ""), &fakeRunner{}) if !errors.Is(err, jump.ErrCancelled) { t.Fatalf("Resolve error = %v, want ErrCancelled", err) } @@ -168,7 +168,7 @@ func TestResolve_VanishedDirectoryErrors(t *testing.T) { var rows []picker.Row var opts picker.Options gone := filepath.Join(root, "tools", "vanished") - if _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, gone)); err == nil { + if _, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, gone), &fakeRunner{}); err == nil { t.Fatal("Resolve: want error for a chosen directory that no longer exists, got nil") } } @@ -192,7 +192,7 @@ func TestResolve_FailingHistoryWriteStillReturnsPath(t *testing.T) { want := filepath.Join(root, "tools", "cdd") var rows []picker.Row var opts picker.Options - got, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, want)) + got, err := jump.Resolve(context.Background(), cfg, hist, capturePick(&rows, &opts, want), &fakeRunner{}) if err != nil { t.Fatalf("Resolve: unexpected error despite a failing History write: %v", err) } diff --git a/internal/picker/actions_test.go b/internal/picker/actions_test.go new file mode 100644 index 0000000..6e566f3 --- /dev/null +++ b/internal/picker/actions_test.go @@ -0,0 +1,154 @@ +package picker_test + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/kryft-dev/cdd/internal/action" + "github.com/kryft-dev/cdd/internal/picker" +) + +// fakeRunner records the Actions Start is asked to run, and fails them +// with err. +type fakeRunner struct { + started []string // "name path" + err error +} + +func (f *fakeRunner) Start(a action.Action, path string) error { + f.started = append(f.started, a.Name+" "+path) + return f.err +} + +func (f *fakeRunner) Run(action.Action, string) (int, error) { + panic("the Picker never runs an Action in the foreground") +} + +// actionModel is a sized Model over twoRowModel's rows with actions bound. +func actionModel(r action.Runner, vim bool, actions ...action.Action) picker.Model { + m := twoRowModel(picker.Options{Vim: vim, Actions: actions, Runner: r}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + return next.(picker.Model) +} + +func press(m picker.Model, msg tea.KeyPressMsg) (picker.Model, tea.Cmd) { + next, cmd := m.Update(msg) + return next.(picker.Model), cmd +} + +var ( + ctrlV = tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl} + ctrlL = tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl} +) + +func TestModel_Action_AttachedQuitsWithTheActionAndRow(t *testing.T) { + lazygit := action.Action{Name: "lazygit", Key: "ctrl+l", Run: "lazygit"} + m := actionModel(&fakeRunner{}, false, lazygit) + + m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m, cmd := press(m, ctrlL) + + row, ok := m.Chosen() + if !ok || row.Project.Name != "beta" { + t.Errorf("Chosen = %v, %v, want beta", row.Project.Name, ok) + } + if got := m.ChosenAction(); got == nil || got.Name != "lazygit" { + t.Errorf("ChosenAction = %v, want lazygit", got) + } + if cmd == nil { + t.Error("Cmd = nil, want tea.Quit") + } +} + +func TestModel_Action_DetachedStartsAndKeepsThePickerOpen(t *testing.T) { + r := &fakeRunner{} + m := actionModel(r, false, action.Action{Name: "code", Key: "ctrl+v", Run: "code {path}", Detach: true}) + + m, cmd := press(m, ctrlV) + + if len(r.started) != 1 || r.started[0] != "code /root/work/alpha" { + t.Errorf("started = %v, want code on alpha", r.started) + } + if _, ok := m.Chosen(); ok || cmd != nil { + t.Errorf("Chosen ok = %v, Cmd = %v, want the Picker still open", ok, cmd != nil) + } +} + +func TestModel_Action_DetachedFailureShowsOnTheFooterUntilTheNextKey(t *testing.T) { + r := &fakeRunner{err: errors.New("no such directory")} + m := actionModel(r, false, action.Action{Name: "code", Key: "ctrl+v", Run: "code", Detach: true}) + + m, _ = press(m, ctrlV) + if v := plain(m.View().Content); !strings.Contains(v, "code: no such directory") { + t.Errorf("View lacks the failure:\n%s", v) + } + + m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if v := plain(m.View().Content); strings.Contains(v, "no such directory") { + t.Errorf("View still shows the failure after a key press:\n%s", v) + } +} + +func TestModel_Action_KeyOverridesANavigationKey(t *testing.T) { + r := &fakeRunner{} + m := actionModel(r, false, action.Action{Name: "code", Key: "ctrl+n", Run: "code", Detach: true}) + + m, _ = press(m, tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}) + m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyEnter}) // the cursor did not move + + if len(r.started) != 1 { + t.Errorf("started = %v, want the Action to run on ctrl+n", r.started) + } + if row, _ := m.Chosen(); row.Project.Name != "alpha" { + t.Errorf("Chosen = %q, want alpha (ctrl+n no longer moves down)", row.Project.Name) + } +} + +func TestModel_Action_ArrowKeysStillMoveWhenCtrlNIsTaken(t *testing.T) { + m := actionModel(&fakeRunner{}, false, action.Action{Name: "code", Key: "ctrl+n", Run: "code", Detach: true}) + + m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m, _ = press(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if row, _ := m.Chosen(); row.Project.Name != "beta" { + t.Errorf("Chosen = %q, want beta", row.Project.Name) + } +} + +func TestModel_Action_PlainKeyFiresOnlyInVimListFocus(t *testing.T) { + r := &fakeRunner{} + help := action.Action{Name: "web", Key: "w", Run: "open", Detach: true} + m := actionModel(r, true, help) + + m, _ = press(m, tea.KeyPressMsg{Code: 'w', Text: "w"}) + if len(r.started) != 1 { + t.Fatalf("started = %v, want w to run in list focus", r.started) + } + + m, _ = press(m, tea.KeyPressMsg{Code: 'f', Text: "f"}) // focus the filter + m, _ = press(m, tea.KeyPressMsg{Code: 'w', Text: "w"}) + if len(r.started) != 1 { + t.Errorf("started = %v, want w to type in the filter focus", r.started) + } + if v := plain(m.View().Content); !strings.Contains(v, "❯ w") { + t.Errorf("filter did not take the w:\n%s", v) + } +} + +func TestModel_Action_NoRowsIsANoOp(t *testing.T) { + r := &fakeRunner{} + m := actionModel(r, false, action.Action{Name: "code", Key: "ctrl+v", Run: "code", Detach: true}) + for _, c := range "zzz" { + m, _ = press(m, tea.KeyPressMsg{Code: c, Text: string(c)}) + } + + m, cmd := press(m, ctrlV) + if len(r.started) != 0 || cmd != nil { + t.Errorf("started = %v, Cmd = %v, want nothing", r.started, cmd != nil) + } +} diff --git a/internal/picker/model.go b/internal/picker/model.go index a4aa923..51f64b4 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/kryft-dev/cdd/internal/action" "github.com/kryft-dev/cdd/internal/git" "github.com/kryft-dev/cdd/internal/match" ) @@ -36,6 +37,9 @@ type Model struct { vim bool layout Layout + actions map[string]action.Action // keyed by Action.Key + runner action.Runner + query string focus focus cursor int // index into the current visible (filtered) rows @@ -50,9 +54,14 @@ type Model struct { // it has. paletteSettled bool - chosen bool - chosenRow Row - quitting bool + // message is the one line the footer shows in place of the key hints + // until the next key press: a detached Action's failure to start. + message string + + chosen bool + chosenRow Row + chosenAction *action.Action + quitting bool } // NewModel builds the Picker's initial Model from rows already in History @@ -66,7 +75,19 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model { if layout == "" { layout = LayoutList } + actions := make(map[string]action.Action, len(opts.Actions)) + for _, a := range opts.Actions { + if a.Key != "" { + actions[a.Key] = a + } + } + runner := opts.Runner + if runner == nil { + runner = action.ExecRunner{} + } return Model{ + actions: actions, + runner: runner, rows: rows, status: status, vim: opts.Vim, @@ -82,13 +103,19 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model { } } -// Chosen returns the Row an "enter" press has chosen, and whether one has +// Chosen returns the Row an "enter" press or an Action key has chosen, and whether one has // been chosen yet. It lets a caller (or a test) read the outcome without // waiting for the Bubble Tea runtime to hand back the final Model. func (m Model) Chosen() (Row, bool) { return m.chosenRow, m.chosen } +// ChosenAction returns the Action that chose the Row, or nil when it was a +// plain Jump. +func (m Model) ChosenAction() *action.Action { + return m.chosenAction +} + // statusResultMsg is the result of one StatusFunc call, keyed by the // Project's path rather than its row index: the fuzzy filter reorders // visible rows while calls are still in flight. diff --git a/internal/picker/picker.go b/internal/picker/picker.go index cbf45d3..8a95a23 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -14,6 +14,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/kryft-dev/cdd/internal/action" "github.com/kryft-dev/cdd/internal/git" ) @@ -43,6 +44,16 @@ type Row struct { Visits int } +// Choice is what the user chose in the Picker: a Project and, unless it is +// a plain Jump, the Action to run on it. A detached Action never ends up +// here, since the Picker runs it and stays open. +type Choice struct { + Row Row + + // Action is the Action to run on the Row, or nil for a plain Jump. + Action *action.Action +} + // StatusFunc reports a directory's git status. The Picker calls it once per // row, concurrently, to fill in the status column and the preview pane // without blocking the screen. @@ -73,6 +84,13 @@ type Options struct { // Layout selects which layout is drawn. The zero value is // LayoutList. Layout Layout + + // Actions are the Actions bound to keys, as config resolves them. + Actions []action.Action + + // Runner starts the detached Actions, which leave the Picker open. The + // zero value is action.ExecRunner. + Runner action.Runner } // concurrency bounds how many StatusFunc calls run at once, so a large @@ -80,17 +98,17 @@ type Options struct { const concurrency = 8 // Run draws the Picker over rows, which must already be in History order, -// and lets the user filter and choose one. It returns the chosen Row and -// true, or the zero Row and false when the user cancels. +// and lets the user filter and choose one. It returns the Choice and true, +// or the zero Choice and false when the user cancels. // // The Picker draws on /dev/tty via tea.OpenTTY, falling back to stderr when // no TTY is available. It never writes to stdout. -func Run(rows []Row, status StatusFunc, opts Options) (Row, bool, error) { +func Run(rows []Row, status StatusFunc, opts Options) (Choice, bool, error) { m := NewModel(rows, status, opts) ttyOpts, cleanup, err := ttyProgramOptions() if err != nil { - return Row{}, false, err + return Choice{}, false, err } if cleanup != nil { defer cleanup() @@ -99,17 +117,17 @@ func Run(rows []Row, status StatusFunc, opts Options) (Row, bool, error) { p := tea.NewProgram(m, ttyOpts...) final, err := p.Run() if err != nil { - return Row{}, false, err + return Choice{}, false, err } fm, ok := final.(Model) if !ok { - return Row{}, false, errors.New("picker: unexpected Model type from Bubble Tea") + return Choice{}, false, errors.New("picker: unexpected Model type from Bubble Tea") } if !fm.chosen { - return Row{}, false, nil + return Choice{}, false, nil } - return fm.chosenRow, true, nil + return Choice{Row: fm.chosenRow, Action: fm.chosenAction}, true, nil } // ttyProgramOptions builds the tea.ProgramOptions that make the Picker draw diff --git a/internal/picker/update.go b/internal/picker/update.go index 236568c..653a500 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -1,7 +1,11 @@ package picker import ( + "fmt" + tea "charm.land/bubbletea/v2" + + "github.com/kryft-dev/cdd/internal/action" ) // Update handles one message: a key press, a status result landing, a @@ -35,8 +39,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// updateKey dispatches a key press by the active key map and focus. +// updateKey dispatches a key press: to the Action bound to it, else by the +// active key map and focus. An Action's key overrides a navigation key of +// the same name. func (m Model) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + m.message = "" + if a, ok := m.boundAction(msg.String()); ok { + return m.runAction(a) + } if m.vim { return m.updateKeyVim(msg) } @@ -121,6 +131,40 @@ func (m Model) updateKeyVim(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } +// boundAction returns the Action bound to key. A plain printable key only +// counts in the vim key map's list focus, elsewhere it is typing. +func (m Model) boundAction(key string) (action.Action, bool) { + a, ok := m.actions[key] + if ok && action.IsPrintable(key) && !(m.vim && m.focus == focusList) { + return action.Action{}, false + } + return a, ok +} + +// runAction runs a on the row under the cursor, when there is one. A +// detached Action starts and the Picker stays open, showing the failure on +// the footer line if it did not start; any other quits with the Action as +// the Choice, for the caller to run once the screen is restored. +func (m Model) runAction(a action.Action) (tea.Model, tea.Cmd) { + rows := m.visibleRows() + if m.cursor < 0 || m.cursor >= len(rows) { + return m, nil + } + row := rows[m.cursor].row + + if !a.Detach { + m.chosen = true + m.chosenRow = row + m.chosenAction = &a + m.quitting = true + return m, tea.Quit + } + if err := m.runner.Start(a, row.Project.Path); err != nil { + m.message = fmt.Sprintf("%s: %v", a.Name, err) + } + return m, nil +} + // moveCursor shifts the cursor by delta rows, clamped to the visible range. func (m *Model) moveCursor(delta int) { m.cursor += delta diff --git a/internal/picker/view.go b/internal/picker/view.go index d81f535..f993101 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -115,6 +115,10 @@ func (m Model) footerView(t theme, width, matched int, met Metrics) string { } count := t.muted_().Render(fmt.Sprintf("%d/%d", matched, len(m.rows))) gap := max(width-lipgloss.Width(keys)-lipgloss.Width(count), 1) + if m.message != "" { + keys = t.fg(t.red).Render(truncateName(m.message, max(width-lipgloss.Width(count)-1, 1))) + gap = max(width-lipgloss.Width(keys)-lipgloss.Width(count), 1) + } lines = append(lines, keys+strings.Repeat(" ", gap)+count) } return strings.Join(lines, "\n")