Skip to content
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand All @@ -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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]` 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
Expand Down Expand Up @@ -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.<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.
- `[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
130 changes: 130 additions & 0 deletions internal/action/action.go
Original file line number Diff line number Diff line change
@@ -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>]).
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.<name>] 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.<name>]
// 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
}
131 changes: 131 additions & 0 deletions internal/action/action_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading