Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]` 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]
Expand Down Expand Up @@ -226,8 +243,8 @@ layout = "list"
uses the vim key map described above. Defaults to `false`, the default
key map.
- `[actions.<name>]`: 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.
Expand Down
5 changes: 5 additions & 0 deletions internal/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package action
import (
"errors"
"fmt"
"runtime"
"slices"
"sort"
)
Expand Down Expand Up @@ -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.
Expand Down
120 changes: 120 additions & 0 deletions internal/action/builtins_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
12 changes: 12 additions & 0 deletions internal/action/open.go
Original file line number Diff line number Diff line change
@@ -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"
}
20 changes: 20 additions & 0 deletions internal/action/open_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion internal/action/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/picker/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/picker/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading