diff --git a/README.md b/README.md index 4c40308..f5a8f53 100644 --- a/README.md +++ b/README.md @@ -166,9 +166,26 @@ yields to yours, 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. The only built-in Action is `jump`, which Jumps to the -Project and is bound to `enter`, in both key maps and in the vim filter -focus. Rebind it, or give `enter` to another Action: +the fields it sets. The built-in Actions are: + +| Action | Key | Runs | +| --- | --- | --- | +| `jump` | `enter` | Jumps to the Project, in both key maps and in the vim filter focus | +| `files` | `ctrl+o` | `xdg-open {path}` (`open {path}` on macOS), detached | +| `editor` | `ctrl+e` | `${VISUAL:-${EDITOR:-vi}} {path}`, on your terminal | + +`editor` reads `$VISUAL`, else `$EDITOR`, else `vi`, each time it runs. +Override `run` to pick a program, or set `key = ""` to unbind either: + +```toml +[actions.editor] +run = "hx {path}" # replaces the built-in command + +[actions.files] +key = "" # no file manager binding +``` + +Rebind `jump`, or give `enter` to another Action: ```toml [actions.jump] @@ -226,8 +243,8 @@ layout = "list" 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. + see [Actions](#actions). Not in the defaults above; `jump`, `files` and + `editor` exist unless you override them. - `[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 index 7377c31..b94e7fc 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -6,6 +6,7 @@ package action import ( "errors" "fmt" + "runtime" "slices" "sort" ) @@ -47,6 +48,10 @@ type Override struct { // table with the same name overrides it field by field. var builtins = []Action{ {Name: "jump", Key: "enter", Jump: true}, + {Name: "files", Key: "ctrl+o", Run: Opener(runtime.GOOS) + " {path}", Detach: true}, + // The shell picks the editor each time the Action runs, so a change to + // $VISUAL or $EDITOR needs no restart. + {Name: "editor", Key: "ctrl+e", Run: "${VISUAL:-${EDITOR:-vi}} {path}"}, } // Builtins returns a copy of the built-in Actions. diff --git a/internal/action/builtins_test.go b/internal/action/builtins_test.go new file mode 100644 index 0000000..dfbd2e3 --- /dev/null +++ b/internal/action/builtins_test.go @@ -0,0 +1,120 @@ +package action_test + +import ( + "os" + "runtime" + "strings" + "testing" + + "github.com/kryft-dev/cdd/internal/action" +) + +func builtin(t *testing.T, name string) action.Action { + t.Helper() + for _, a := range action.Builtins() { + if a.Name == name { + return a + } + } + t.Fatalf("no built-in Action %q", name) + return action.Action{} +} + +func ptr[T any](v T) *T { return &v } + +func TestBuiltins_FilesOpensTheProjectDetached(t *testing.T) { + got := builtin(t, "files") + want := action.Action{Name: "files", Key: "ctrl+o", Run: action.Opener(runtime.GOOS) + " {path}", Detach: true} + if got != want { + t.Errorf("files = %+v, want %+v", got, want) + } +} + +func TestBuiltins_EditorIsBoundAndNotDetached(t *testing.T) { + got := builtin(t, "editor") + if got.Key != "ctrl+e" || got.Detach || got.Jump || got.Run == "" { + t.Errorf("editor = %+v, want ctrl+e, a command, attached", got) + } +} + +// The editor is looked up by the shell when the Action runs, so these run +// the real command with a stand-in for each editor. +func TestBuiltins_EditorPrefersVisualThenEditorThenVi(t *testing.T) { + tests := []struct { + name string + visual, editor string + want string + }{ + {"visual wins", "echo visual", "echo editor", "visual"}, + {"editor when visual is empty", "", "echo editor", "editor"}, + {"vi when neither is set", "", "", "vi"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("VISUAL", tt.visual) + t.Setenv("EDITOR", tt.editor) + bin := t.TempDir() + if err := os.WriteFile(bin+"/vi", []byte("#!/bin/sh\necho vi\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+":"+os.Getenv("PATH")) + dir, tty := spacedDir(t), fakeTTY(t) + + code, err := action.ExecRunner{TTY: tty}.Run(builtin(t, "editor"), dir) + if err != nil || code != 0 { + t.Fatalf("Run = %d, %v", code, err) + } + out, _ := os.ReadFile(tty) + if got := strings.TrimSpace(string(out)); !strings.HasPrefix(got, tt.want) { + t.Errorf("output = %q, want it to start with %q", got, tt.want) + } + }) + } +} + +func TestBuiltins_EditorGetsTheQuotedPathAfterItsOwnWords(t *testing.T) { + t.Setenv("VISUAL", "printf %s, ") + dir, tty := spacedDir(t), fakeTTY(t) + + if _, err := (action.ExecRunner{TTY: tty}).Run(builtin(t, "editor"), dir); err != nil { + t.Fatal(err) + } + if out, _ := os.ReadFile(tty); string(out) != dir+"," { + t.Errorf("output = %q, want the path as one last argument", out) + } +} + +func TestMerge_UserOverridesFilesAndEditor(t *testing.T) { + got, err := action.Merge(map[string]action.Override{ + "files": {Run: ptr("thunar {path}"), Key: ptr("ctrl+f")}, + "editor": {Run: ptr("hx {path}")}, + }, false) + if err != nil { + t.Fatalf("Merge: %v", err) + } + byName := map[string]action.Action{} + for _, a := range got { + byName[a.Name] = a + } + if f := byName["files"]; f.Run != "thunar {path}" || f.Key != "ctrl+f" || !f.Detach { + t.Errorf("files = %+v", f) + } + if e := byName["editor"]; e.Run != "hx {path}" || e.Key != "ctrl+e" || e.Detach { + t.Errorf("editor = %+v", e) + } +} + +func TestMerge_FilesAndEditorCanBeUnbound(t *testing.T) { + got, err := action.Merge(map[string]action.Override{ + "files": {Key: ptr("")}, + "editor": {Key: ptr("")}, + }, false) + if err != nil { + t.Fatalf("Merge: %v", err) + } + for _, a := range got { + if (a.Name == "files" || a.Name == "editor") && a.Key != "" { + t.Errorf("%s key = %q, want unbound", a.Name, a.Key) + } + } +} diff --git a/internal/action/open.go b/internal/action/open.go new file mode 100644 index 0000000..c06ac06 --- /dev/null +++ b/internal/action/open.go @@ -0,0 +1,12 @@ +package action + +// Opener returns the program that opens a path or URL in the desktop's +// default application on goos (a runtime.GOOS value): "open" on macOS, +// "xdg-open" everywhere else. Built-in Actions that hand something to the +// desktop use it, so the platform choice lives in one place. +func Opener(goos string) string { + if goos == "darwin" { + return "open" + } + return "xdg-open" +} diff --git a/internal/action/open_test.go b/internal/action/open_test.go new file mode 100644 index 0000000..ae1994c --- /dev/null +++ b/internal/action/open_test.go @@ -0,0 +1,20 @@ +package action_test + +import ( + "testing" + + "github.com/kryft-dev/cdd/internal/action" +) + +func TestOpener_PicksThePlatformProgram(t *testing.T) { + tests := []struct{ goos, want string }{ + {"linux", "xdg-open"}, + {"darwin", "open"}, + {"freebsd", "xdg-open"}, + } + for _, tt := range tests { + if got := action.Opener(tt.goos); got != tt.want { + t.Errorf("Opener(%q) = %q, want %q", tt.goos, got, tt.want) + } + } +} diff --git a/internal/action/run.go b/internal/action/run.go index 959bfe8..4e60972 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -70,7 +70,7 @@ func (r ExecRunner) Run(a Action, path string) (int, error) { if err != nil { return 0, fmt.Errorf("open terminal: %w", err) } - defer tty.Close() + defer func() { _ = tty.Close() }() cmd := Command(a, path) cmd.Stdin, cmd.Stdout, cmd.Stderr = tty, tty, tty diff --git a/internal/picker/actions_test.go b/internal/picker/actions_test.go index 1d69f00..ad62466 100644 --- a/internal/picker/actions_test.go +++ b/internal/picker/actions_test.go @@ -215,7 +215,7 @@ func TestModel_Action_NoRowsIsANoOp(t *testing.T) { m, _ = press(m, tea.KeyPressMsg{Code: c, Text: string(c)}) } - m, cmd := press(m, ctrlV) + _, 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/update.go b/internal/picker/update.go index 346bc4c..f5ced0d 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -129,7 +129,7 @@ func (m Model) updateKeyVim(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // 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) { + if ok && action.IsPrintable(key) && (!m.vim || m.focus != focusList) { return action.Action{}, false } return a, ok