diff --git a/CONTEXT.md b/CONTEXT.md index 5cf3782..dd6dfe5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,7 +41,7 @@ One of the arrangements in which the Picker draws Projects. The List Layout, the _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. +A named command bound to a key in the Picker and run on the selected Project. Jumping on Enter is the built-in `jump` Action. 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**: diff --git a/README.md b/README.md index 9614390..8099d14 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Default key map: | type | filter the list | | `↑` / `ctrl+p` | move up | | `↓` / `ctrl+n` | move down | -| `enter` | Jump to the selected Project | +| `enter` | Jump to the selected Project (the `jump` Action) | | `esc` / `ctrl+c` | cancel | | `ctrl+u` | clear the filter | @@ -126,7 +126,7 @@ Vim key map (`keys.vim = true`): the list is focused on open. | `f` / `/` | focus the filter | | `esc` (filter) | return to the list, keeping the query | | `esc` / `q` | cancel | -| `enter` | Jump to the selected Project | +| `enter` | Jump to the selected Project (the `jump` Action) | ### Actions @@ -165,7 +165,23 @@ 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. +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: + +```toml +[actions.jump] +key = "alt+enter" # Jump moves here + +[actions.code] +key = "enter" # Enter now opens VS Code +run = "code {path}" +detach = true +``` + +`jump` holds `enter` until you move it, so giving `enter` to another Action +means moving `jump` or setting `[actions.jump] key = ""`, which leaves it +unbound. That is allowed; `jump` just has no key. ### Layout diff --git a/internal/action/action.go b/internal/action/action.go index f654783..f52fce3 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -45,7 +45,9 @@ type Override struct { // 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{} +var builtins = []Action{ + {Name: "jump", Key: "enter", Jump: true}, +} // Builtins returns a copy of the built-in Actions. func Builtins() []Action { diff --git a/internal/action/action_test.go b/internal/action/action_test.go index 377ec90..ccc7c8c 100644 --- a/internal/action/action_test.go +++ b/internal/action/action_test.go @@ -129,3 +129,60 @@ func TestMerge_JumpNeedsNoRun(t *testing.T) { t.Fatalf("Merge: %v", err) } } + +func TestBuiltins_JumpIsBoundToEnterAndNeedsNoCommand(t *testing.T) { + var got *Action + for _, a := range Builtins() { + if a.Name == "jump" { + got = &a + } + } + if got == nil || got.Key != "enter" || got.Run != "" || !got.Jump || got.Detach { + t.Fatalf("jump built-in = %+v, want a Jump bound to enter with no command", got) + } +} + +func TestMerge_JumpRebindsAndEnterGoesToAnotherAction(t *testing.T) { + detach := true + got, err := Merge(map[string]Override{ + "jump": {Key: str("alt+enter")}, + "code": {Key: str("enter"), Run: str("code {path}"), Detach: &detach}, + }, false) + if err != nil { + t.Fatalf("Merge: %v", err) + } + keys := map[string]string{} + for _, a := range got { + keys[a.Name] = a.Key + } + if keys["jump"] != "alt+enter" || keys["code"] != "enter" { + t.Errorf("keys = %v, want jump on alt+enter and code on enter", keys) + } +} + +func TestMerge_EnterNeedsJumpMovedOrUnboundBeforeAnotherActionTakesIt(t *testing.T) { + code := Override{Key: str("enter"), Run: str("code {path}")} + + if _, err := Merge(map[string]Override{"code": code}, false); err == nil { + t.Error("Merge succeeded with jump still on enter, want a conflict") + } + + got, err := Merge(map[string]Override{"code": code, "jump": {Key: str("")}}, false) + if err != nil { + t.Fatalf("Merge: %v", err) + } + keys := map[string]string{} + for _, a := range got { + keys[a.Name] = a.Key + } + if keys["code"] != "enter" || keys["jump"] != "" { + t.Errorf("keys = %v, want code on enter and jump unbound", keys) + } +} + +func TestMerge_JumpMayBeLeftUnbound(t *testing.T) { + got, err := Merge(map[string]Override{"jump": {Key: str("")}}, 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) + } +} diff --git a/internal/picker/actions_test.go b/internal/picker/actions_test.go index 6e566f3..1d69f00 100644 --- a/internal/picker/actions_test.go +++ b/internal/picker/actions_test.go @@ -41,6 +41,9 @@ func press(m picker.Model, msg tea.KeyPressMsg) (picker.Model, tea.Cmd) { return next.(picker.Model), cmd } +// jump is the built-in Jump, bound to enter. +var jump = action.Builtins()[0] + var ( ctrlV = tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl} ctrlL = tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl} @@ -96,7 +99,7 @@ func TestModel_Action_DetachedFailureShowsOnTheFooterUntilTheNextKey(t *testing. 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 := actionModel(r, false, jump, 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 @@ -110,7 +113,7 @@ func TestModel_Action_KeyOverridesANavigationKey(t *testing.T) { } func TestModel_Action_ArrowKeysStillMoveWhenCtrlNIsTaken(t *testing.T) { - m := actionModel(&fakeRunner{}, false, action.Action{Name: "code", Key: "ctrl+n", Run: "code", Detach: true}) + m := actionModel(&fakeRunner{}, false, jump, 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}) @@ -140,6 +143,71 @@ func TestModel_Action_PlainKeyFiresOnlyInVimListFocus(t *testing.T) { } } +var ( + enter = tea.KeyPressMsg{Code: tea.KeyEnter} + altEnter = tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt} +) + +func TestModel_Jump_EnterJumpsInBothKeyMapsAndBothVimFocuses(t *testing.T) { + tests := []struct { + name string + vim bool + keys []tea.KeyPressMsg + }{ + {"default", false, nil}, + {"vim list focus", true, nil}, + {"vim filter focus", true, []tea.KeyPressMsg{{Code: 'f', Text: "f"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := actionModel(&fakeRunner{}, tt.vim, action.Builtins()...) + for _, k := range tt.keys { + m, _ = press(m, k) + } + + m, cmd := press(m, enter) + + row, ok := m.Chosen() + if !ok || row.Project.Name != "alpha" || cmd == nil { + t.Errorf("Chosen = %q, %v, Cmd = %v, want alpha and a quit", row.Project.Name, ok, cmd != nil) + } + if got := m.ChosenAction(); got == nil || got.Name != "jump" || !got.Jump { + t.Errorf("ChosenAction = %v, want jump", got) + } + }) + } +} + +func TestModel_Jump_EnterDoesNothingWhenNoActionIsBoundToIt(t *testing.T) { + m := actionModel(&fakeRunner{}, false) + + m, cmd := press(m, enter) + + if _, ok := m.Chosen(); ok || cmd != nil { + t.Errorf("Chosen ok = %v, Cmd = %v, want enter to be unbound", ok, cmd != nil) + } +} + +func TestModel_Jump_MovedToAnotherKeyJumpsThere(t *testing.T) { + r := &fakeRunner{} + code := action.Action{Name: "code", Key: "enter", Run: "code {path}", Detach: true} + moved := action.Action{Name: "jump", Key: "alt+enter", Jump: true} + m := actionModel(r, false, moved, code) + + m, _ = press(m, enter) + if len(r.started) != 1 || r.started[0] != "code /root/work/alpha" { + t.Fatalf("started = %v, want enter to run code", r.started) + } + if _, ok := m.Chosen(); ok { + t.Fatal("enter Jumped although code owns it") + } + + m, _ = press(m, altEnter) + if got := m.ChosenAction(); got == nil || got.Name != "jump" { + t.Errorf("ChosenAction = %v, want jump on alt+enter", got) + } +} + func TestModel_Action_NoRowsIsANoOp(t *testing.T) { r := &fakeRunner{} m := actionModel(r, false, action.Action{Name: "code", Key: "ctrl+v", Run: "code", Detach: true}) diff --git a/internal/picker/filter_test.go b/internal/picker/filter_test.go index ec012ee..8e7b24b 100644 --- a/internal/picker/filter_test.go +++ b/internal/picker/filter_test.go @@ -5,6 +5,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/kryft-dev/cdd/internal/action" "github.com/kryft-dev/cdd/internal/picker" ) @@ -57,7 +58,7 @@ func TestModel_FilterWords(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := typed(picker.NewModel(sameNameRows(), noopStatus, picker.Options{}), tt.query) + m := typed(picker.NewModel(sameNameRows(), noopStatus, picker.Options{Actions: action.Builtins()}), tt.query) if got := chosen(m); got != tt.want { t.Errorf("query %q chose %q, want %q", tt.query, got, tt.want) } @@ -68,7 +69,7 @@ func TestModel_FilterWords(t *testing.T) { // TestModel_FilterTiesKeepHistoryOrder checks that equally good matches // stay in History order, the newer first. func TestModel_FilterTiesKeepHistoryOrder(t *testing.T) { - m := typed(picker.NewModel(sameNameRows(), noopStatus, picker.Options{}), " barbar") + m := typed(picker.NewModel(sameNameRows(), noopStatus, picker.Options{Actions: action.Builtins()}), " barbar") if got, want := chosen(m), "/root/domain/foo.com/barbar"; got != want { t.Errorf("chose %q, want %q", got, want) } diff --git a/internal/picker/model.go b/internal/picker/model.go index 51f64b4..52cfd88 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -103,15 +103,14 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model { } } -// Chosen returns the Row an "enter" press or an Action key has chosen, and whether one has +// Chosen returns the Row 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. +// ChosenAction returns the Action that chose the Row, or nil when none has. func (m Model) ChosenAction() *action.Action { return m.chosenAction } diff --git a/internal/picker/model_test.go b/internal/picker/model_test.go index b00825b..df0f768 100644 --- a/internal/picker/model_test.go +++ b/internal/picker/model_test.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/picker" ) @@ -61,7 +62,7 @@ func TestModel_FuzzyFilter(t *testing.T) { {Project: picker.Project{Dir: "~/work/", Name: "billing", Path: "/root/work/billing"}}, {Project: picker.Project{Dir: "~/oss/", Name: "bubbletea", Path: "/root/oss/bubbletea"}}, } - m := picker.NewModel(rows, noopStatus, picker.Options{}) + m := picker.NewModel(rows, noopStatus, picker.Options{Actions: action.Builtins()}) next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) m = next.(picker.Model) diff --git a/internal/picker/picker.go b/internal/picker/picker.go index 8a95a23..6c7d9eb 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -44,13 +44,14 @@ 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. +// Choice is what the user chose in the Picker: a Project and the Action to +// run on it, the built-in jump for a plain Enter. 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 is the Action to run on the Row. The Picker always sets it; a + // nil one is taken as a plain Jump. Action *action.Action } diff --git a/internal/picker/update.go b/internal/picker/update.go index 653a500..346bc4c 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -54,7 +54,7 @@ func (m Model) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } // updateKeyDefault implements the default key map: typing filters, arrow -// keys (and ctrl+p/ctrl+n) move, enter chooses, esc cancels, ctrl+u clears. +// keys (and ctrl+p/ctrl+n) move, esc cancels, ctrl+u clears. func (m Model) updateKeyDefault(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", "ctrl+p": @@ -63,8 +63,6 @@ func (m Model) updateKeyDefault(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "down", "ctrl+n": m.moveCursor(1) return m, nil - case "enter": - return m.choose() case "esc", "ctrl+c": return m.cancel() case "ctrl+u": @@ -86,15 +84,13 @@ func (m Model) updateKeyDefault(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // updateKeyVim implements the vim key map. The list is focused on open; // j/k move, g/G jump to the ends, f or / focuses the filter, esc in the // filter returns to the list keeping the query, esc or q on the list -// cancels, enter chooses from either mode. +// cancels. func (m Model) updateKeyVim(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.focus == focusFilter { switch msg.String() { case "esc": m.focus = focusList return m, nil - case "enter": - return m.choose() case "backspace": m.backspace() return m, nil @@ -123,8 +119,6 @@ func (m Model) updateKeyVim(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "f", "/": m.focus = focusFilter return m, nil - case "enter": - return m.choose() case "esc", "q", "ctrl+c": return m.cancel() } @@ -202,17 +196,6 @@ func (m *Model) backspace() { m.cursor = 0 } -// choose selects the row under the cursor, when there is one, and quits. -func (m Model) choose() (tea.Model, tea.Cmd) { - rows := m.visibleRows() - if m.cursor >= 0 && m.cursor < len(rows) { - m.chosen = true - m.chosenRow = rows[m.cursor].row - } - m.quitting = true - return m, tea.Quit -} - // cancel quits without a choice. func (m Model) cancel() (tea.Model, tea.Cmd) { m.chosen = false diff --git a/internal/picker/update_test.go b/internal/picker/update_test.go index 44e6838..9a7a768 100644 --- a/internal/picker/update_test.go +++ b/internal/picker/update_test.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/picker" ) @@ -20,7 +21,7 @@ func twoRowModel(opts picker.Options) picker.Model { } func TestModel_Update_EscCancels(t *testing.T) { - m := twoRowModel(picker.Options{}) + m := twoRowModel(picker.Options{Actions: action.Builtins()}) next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) final := next.(picker.Model) @@ -33,7 +34,7 @@ func TestModel_Update_EscCancels(t *testing.T) { } func TestModel_Update_CtrlUClearsFilter(t *testing.T) { - m := twoRowModel(picker.Options{Query: "alpha"}) + m := twoRowModel(picker.Options{Query: "alpha", Actions: action.Builtins()}) next, _ := m.Update(tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl}) final := next.(picker.Model) @@ -129,7 +130,7 @@ func TestModel_Update_StatusResult(t *testing.T) { } func TestModel_Update_VimKeys(t *testing.T) { - m := twoRowModel(picker.Options{Vim: true}) + m := twoRowModel(picker.Options{Vim: true, Actions: action.Builtins()}) next, _ := m.Update(tea.KeyPressMsg{Text: "j"}) m = next.(picker.Model) @@ -146,7 +147,7 @@ func TestModel_Update_VimKeys(t *testing.T) { } func TestModel_Update_VimQuitsOnQ(t *testing.T) { - m := twoRowModel(picker.Options{Vim: true}) + m := twoRowModel(picker.Options{Vim: true, Actions: action.Builtins()}) next, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) final := next.(picker.Model) diff --git a/internal/picker/view_list_test.go b/internal/picker/view_list_test.go index 0d03665..468d60a 100644 --- a/internal/picker/view_list_test.go +++ b/internal/picker/view_list_test.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/kryft-dev/cdd/internal/action" "github.com/kryft-dev/cdd/internal/picker" ) @@ -33,7 +34,7 @@ func TestOptions_LayoutDefaultsToList(t *testing.T) { // rows in the order they were given (History order) rather than regrouping // them by parent directory. func TestModel_ListLayout_FlatHistoryOrder(t *testing.T) { - m := picker.NewModel(historyRows(), fakeStatus, picker.Options{Layout: picker.LayoutList}) + m := picker.NewModel(historyRows(), fakeStatus, picker.Options{Layout: picker.LayoutList, Actions: action.Builtins()}) want := []string{ "/root/tools/cdd",