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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ Vim key map (`keys.vim = true`): the list is focused on open.
| `esc` (filter) | return to the list, keeping the query |
| `esc` / `q` | cancel |
| `enter` | Jump to the selected Project (the `jump` Action) |
| `?` | open an overlay of every key, including your Actions' (list focus), and `?`, `esc` or `q` to close it |

### Key hints

The last line of the Picker lists the keys of your Actions, e.g.
`enter jump · ctrl+o files · ctrl+e editor · …`, with the match count at its
right edge. Whatever holds `enter` comes first, then your own `[actions.*]`
in the order `config.toml` declares them, then the built-ins; an Action with
`key = ""` is not listed. A line too narrow for all of them drops whole hints
from the end behind a `…`. A failure or confirmation (such as `copied`)
replaces the hints until the next key press.

With `keys.vim = true` the line also points to `?`, which opens an overlay of
every key, navigation included, and `?`, `esc` or `q` closes it. An Action
you bind to `?` takes the key from the overlay, and the default key map has
no overlay, since `?` is typing there. Set `[picker] hints = false` to hide
the hints, leaving the count and messages.

### Actions

Expand Down Expand Up @@ -251,6 +268,7 @@ max_visits = 1000
vim = false
[picker]
layout = "list"
hints = true
```

- `exclude`: glob patterns (`filepath.Match` semantics) for directories
Expand All @@ -271,6 +289,8 @@ layout = "list"
- `[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.
- `[picker].hints`: when `true` (the default), the last line lists the keys
of your Actions; see [Key hints](#key-hints). `false` hides them.

Upgrading from v0.2: `root` and the `"grouped"` layout are gone. Delete
them from `config.toml` (cdd names the offending line), then run `cdd scan`
Expand Down
46 changes: 31 additions & 15 deletions internal/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,11 @@ func (e *Error) Error() string {
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.
// result in the order the Picker lists them: the user's own Actions first,
// in the order of order (the names of the user's tables as config.toml
// lists them; any it leaves out follow, sorted by name), then the
// built-ins in their own order. It fails on the first invalid Action with
// an *Error.
//
// A key a user table sets wins over an Action that only holds it by default,
// which is left unbound as if the user had set its key to "". Two Actions
Expand All @@ -94,23 +97,36 @@ func (e *Error) Unwrap() error { return e.Err }
//
// 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
func Merge(user map[string]Override, order []string, vim bool) ([]Action, error) {
var out []Action
index := make(map[string]int, len(user)+len(builtins))
isBuiltin := make(map[string]bool, len(builtins))
for _, a := range builtins {
isBuiltin[a.Name] = true
}

var added []string
for name := range user {
if _, ok := index[name]; !ok {
added = append(added, name)
add := func(name string) {
if _, ok := user[name]; ok && !isBuiltin[name] {
if _, seen := index[name]; !seen {
index[name] = len(out)
out = append(out, Action{Name: name})
}
}
}
sort.Strings(added)
for _, name := range added {
index[name] = len(out)
out = append(out, Action{Name: name})
for _, name := range order {
add(name)
}
var rest []string
for name := range user {
rest = append(rest, name)
}
sort.Strings(rest)
for _, name := range rest {
add(name)
}
for _, a := range builtins {
index[a.Name] = len(out)
out = append(out, a)
}

for name, o := range user {
Expand Down
45 changes: 30 additions & 15 deletions internal/action/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,21 @@ func names(as []Action) []string {
return out
}

func TestMerge_UserActionsFollowBuiltinsSortedByName(t *testing.T) {
func TestMerge_UserActionsComeFirstInConfigOrderThenBuiltins(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)
"mid": {Run: str("mid")},
}, []string{"zed", "files", "code"}, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}

if want := []string{"files", "code", "zed"}; !slices.Equal(names(got), want) {
// "files" in the order is a built-in and stays with them; "mid", which
// the order leaves out, follows the ones it lists.
if want := []string{"zed", "code", "mid", "files"}; !slices.Equal(names(got), want) {
t.Fatalf("names = %v, want %v", names(got), want)
}
code := got[1]
Expand All @@ -45,10 +48,22 @@ func TestMerge_UserActionsFollowBuiltinsSortedByName(t *testing.T) {
}
}

func TestMerge_AnOrderNamingTheSameActionTwiceListsItOnce(t *testing.T) {
withBuiltins(t)

got, err := Merge(map[string]Override{"a": {Run: str("a")}}, []string{"a", "a", "nope"}, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
if want := []string{"a"}; !slices.Equal(names(got), want) {
t.Errorf("names = %v, want %v", names(got), want)
}
}

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)
got, err := Merge(map[string]Override{"files": {Key: str("ctrl+f")}}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand All @@ -62,7 +77,7 @@ func TestMerge_OverridesABuiltinFieldByField(t *testing.T) {
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)
got, err := Merge(map[string]Override{"files": {Key: str("")}}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand Down Expand Up @@ -93,7 +108,7 @@ func TestMerge_Errors(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
withBuiltins(t)
_, err := Merge(tt.user, tt.vim)
_, err := Merge(tt.user, nil, tt.vim)
var ae *Error
if !errors.As(err, &ae) {
t.Fatalf("err = %v, want *Error", err)
Expand All @@ -108,7 +123,7 @@ func TestMerge_Errors(t *testing.T) {
func TestMerge_UserKeyDisplacesABuiltinHoldingIt(t *testing.T) {
withBuiltins(t, Action{Name: "files", Key: "ctrl+o", Run: "xdg-open {path}"})

got, err := Merge(map[string]Override{"code": {Key: str("ctrl+o"), Run: str("code")}}, false)
got, err := Merge(map[string]Override{"code": {Key: str("ctrl+o"), Run: str("code")}}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand All @@ -127,7 +142,7 @@ func TestMerge_UserRebindingABuiltinDisplacesAnotherBuiltin(t *testing.T) {
Action{Name: "remote", Key: "ctrl+r", Run: "open-remote {path}"},
)

got, err := Merge(map[string]Override{"remote": {Key: str("enter")}}, false)
got, err := Merge(map[string]Override{"remote": {Key: str("enter")}}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand All @@ -146,7 +161,7 @@ func TestMerge_UserRebindingAnEarlierBuiltinDisplacesALaterOne(t *testing.T) {
Action{Name: "b", Key: "ctrl+b", Run: "y"},
)

got, err := Merge(map[string]Override{"a": {Key: str("ctrl+b")}}, false)
got, err := Merge(map[string]Override{"a": {Key: str("ctrl+b")}}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand All @@ -159,7 +174,7 @@ func TestMerge_TwoUserActionsOnEnterIsAnError(t *testing.T) {
_, err := Merge(map[string]Override{
"code": {Key: str("enter"), Run: str("code {path}")},
"edit": {Key: str("enter"), Run: str("vi {path}")},
}, false)
}, nil, false)
var ae *Error
if !errors.As(err, &ae) || ae.Name != "edit" || ae.Field != "key" {
t.Fatalf("err = %v, want an *Error for edit's key", err)
Expand All @@ -168,15 +183,15 @@ func TestMerge_TwoUserActionsOnEnterIsAnError(t *testing.T) {

func TestMerge_PrintableKeyAllowedWithVim(t *testing.T) {
withBuiltins(t)
got, err := Merge(map[string]Override{"a": {Key: str("?"), Run: str("x")}}, true)
got, err := Merge(map[string]Override{"a": {Key: str("?"), Run: str("x")}}, nil, 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 {
if _, err := Merge(nil, nil, false); err != nil {
t.Fatalf("Merge: %v", err)
}
}
Expand All @@ -198,7 +213,7 @@ func TestMerge_JumpRebindsAndEnterGoesToAnotherAction(t *testing.T) {
got, err := Merge(map[string]Override{
"jump": {Key: str("alt+enter")},
"code": {Key: str("enter"), Run: str("code {path}"), Detach: &detach},
}, false)
}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand All @@ -212,7 +227,7 @@ func TestMerge_JumpRebindsAndEnterGoesToAnotherAction(t *testing.T) {
}

func TestMerge_EnterGoesToAnotherActionAndJumpIsLeftUnbound(t *testing.T) {
got, err := Merge(map[string]Override{"code": {Key: str("enter"), Run: str("code {path}")}}, false)
got, err := Merge(map[string]Override{"code": {Key: str("enter"), Run: str("code {path}")}}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand All @@ -226,7 +241,7 @@ func TestMerge_EnterGoesToAnotherActionAndJumpIsLeftUnbound(t *testing.T) {
}

func TestMerge_JumpMayBeLeftUnbound(t *testing.T) {
got, err := Merge(map[string]Override{"jump": {Key: str("")}}, false)
got, err := Merge(map[string]Override{"jump": {Key: str("")}}, nil, false)
if err != nil || got[0].Name != "jump" || got[0].Key != "" || !got[0].Jump {
t.Fatalf("got %+v, %v, want an unbound jump", got, err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/action/builtins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ 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)
}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand All @@ -108,7 +108,7 @@ func TestMerge_FilesAndEditorCanBeUnbound(t *testing.T) {
got, err := action.Merge(map[string]action.Override{
"files": {Key: ptr("")},
"editor": {Key: ptr("")},
}, false)
}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/action/copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestBuiltins_CopyIsBoundToCtrlYWithNoCommand(t *testing.T) {
func TestMerge_CopyKeepsCopyUntilTheUserSetsRun(t *testing.T) {
merged := func(o action.Override) action.Action {
t.Helper()
got, err := action.Merge(map[string]action.Override{"copy": o}, false)
got, err := action.Merge(map[string]action.Override{"copy": o}, nil, false)
if err != nil {
t.Fatalf("Merge: %v", err)
}
Expand Down
29 changes: 28 additions & 1 deletion internal/config/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
// 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)
resolved, err := action.Merge(c.Actions, actionOrder(data), c.Keys.Vim)
if err != nil {
var ae *action.Error
if errors.As(err, &ae) {
Expand All @@ -33,6 +33,33 @@ var (
keyLine = regexp.MustCompile(`^\s*([A-Za-z0-9_-]+)\s*=`)
)

// actionOrder returns the names of the [actions.<name>] tables in the order
// data declares them, which the Picker lists them in. A name TOML lets be
// declared another way (dotted keys under a bare [actions] table) is left
// out, and Merge puts it after the rest.
func actionOrder(data []byte) []string {
var names []string
for _, line := range strings.Split(string(data), "\n") {
m := tableHeader.FindStringSubmatch(line)
if m == nil {
continue
}
name, ok := strings.CutPrefix(m[1], "actions.")
if !ok {
continue
}
if strings.HasPrefix(name, `"`) {
unquoted, err := strconv.Unquote(name)
if err != nil {
continue
}
name = unquoted
}
names = append(names, name)
}
return names
}

// actionLine returns the 1-based line of field in the [actions.<name>]
// 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
Expand Down
40 changes: 36 additions & 4 deletions internal/config/actions_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config_test

import (
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -28,7 +29,7 @@ jump = true
{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:]
got := cfg.ResolvedActions[:2]
for i := range want {
if got[i] != want[i] {
t.Errorf("action %d = %+v, want %+v", i, got[i], want[i])
Expand Down Expand Up @@ -79,8 +80,39 @@ func TestLoadFromPrintableActionKeyAllowedWithVim(t *testing.T) {
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)
first := cfg.ResolvedActions[0]
if first.Name != "help" || first.Key != "?" {
t.Errorf("first action = %+v", first)
}
}

func TestLoadFromActionsKeepConfigOrderBeforeTheBuiltins(t *testing.T) {
cfg, err := config.LoadFrom(writeConfig(t, `
[actions.zed]
run = "zed {path}"

[actions.editor]
run = "hx {path}"

[actions."a b"]
run = "x"

[actions.code]
run = "code {path}"
`))
if err != nil {
t.Fatalf("LoadFrom: %v", err)
}

var got []string
for _, a := range cfg.ResolvedActions {
got = append(got, a.Name)
}
want := []string{"zed", "a b", "code"}
for _, a := range action.Builtins() {
want = append(want, a.Name)
}
if !slices.Equal(got, want) {
t.Errorf("order = %v, want %v", got, want)
}
}
Loading
Loading