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
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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

Expand Down Expand Up @@ -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.<name>]` 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

Expand Down
4 changes: 3 additions & 1 deletion internal/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>]
// 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 {
Expand Down
57 changes: 57 additions & 0 deletions internal/action/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
72 changes: 70 additions & 2 deletions internal/picker/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
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}
Expand Down Expand Up @@ -96,7 +99,7 @@

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
Expand All @@ -110,7 +113,7 @@
}

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})
Expand Down Expand Up @@ -140,6 +143,71 @@
}
}

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})
Expand All @@ -147,7 +215,7 @@
m, _ = press(m, tea.KeyPressMsg{Code: c, Text: string(c)})
}

m, cmd := press(m, ctrlV)

Check failure on line 218 in internal/picker/actions_test.go

View workflow job for this annotation

GitHub Actions / lint

ineffectual assignment to m (ineffassign)
if len(r.started) != 0 || cmd != nil {
t.Errorf("started = %v, Cmd = %v, want nothing", r.started, cmd != nil)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/picker/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

tea "charm.land/bubbletea/v2"

"github.com/kryft-dev/cdd/internal/action"
"github.com/kryft-dev/cdd/internal/picker"
)

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
5 changes: 2 additions & 3 deletions internal/picker/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion internal/picker/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 5 additions & 4 deletions internal/picker/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
21 changes: 2 additions & 19 deletions internal/picker/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
}

// 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":
Expand All @@ -63,8 +63,6 @@
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":
Expand All @@ -86,15 +84,13 @@
// 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
Expand Down Expand Up @@ -123,8 +119,6 @@
case "f", "/":
m.focus = focusFilter
return m, nil
case "enter":
return m.choose()
case "esc", "q", "ctrl+c":
return m.cancel()
}
Expand All @@ -135,7 +129,7 @@
// 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) {

Check failure on line 132 in internal/picker/update.go

View workflow job for this annotation

GitHub Actions / lint

QF1001: could apply De Morgan's law (staticcheck)
return action.Action{}, false
}
return a, ok
Expand Down Expand Up @@ -202,17 +196,6 @@
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
Expand Down
Loading
Loading