Skip to content

Commit c50f701

Browse files
marcusclaude
andcommitted
feat: add g/G and j/k vim navigation to project search (td-3b232874)
Add a ResultsFocused mode to project search that enables vim-style navigation keys (j/k for up/down, g/G for top/bottom). Tab toggles between input-focused mode (typing) and results-focused mode (navigation). Arrow keys auto-focus results; typing auto-focuses input. Visual feedback shows a block cursor when input-focused and thin cursor when results-focused. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8688dbc commit c50f701

6 files changed

Lines changed: 172 additions & 7 deletions

File tree

internal/keymap/bindings.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,10 @@ func DefaultBindings() []Binding {
348348
{Key: "ctrl+e", Command: "open-in-editor", Context: "file-browser-project-search"},
349349
{Key: "ctrl+d", Command: "page-down", Context: "file-browser-project-search"},
350350
{Key: "ctrl+u", Command: "page-up", Context: "file-browser-project-search"},
351+
{Key: "j", Command: "cursor-down", Context: "file-browser-project-search"},
352+
{Key: "k", Command: "cursor-up", Context: "file-browser-project-search"},
353+
{Key: "g", Command: "cursor-top", Context: "file-browser-project-search"},
354+
{Key: "G", Command: "cursor-bottom", Context: "file-browser-project-search"},
351355

352356
// File browser file operation context
353357
{Key: "esc", Command: "cancel", Context: "file-browser-file-op"},

internal/plugins/filebrowser/handlers.go

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,48 @@ func (p *Plugin) handleProjectSearchKey(msg tea.KeyMsg) (plugin.Plugin, tea.Cmd)
10731073
}
10741074
}
10751075

1076+
// Tab toggles between input-focused and results-focused modes.
1077+
// When results-focused, j/k/g/G navigate results (vim parity).
1078+
if key == "tab" && state != nil && len(state.Results) > 0 {
1079+
state.ResultsFocused = !state.ResultsFocused
1080+
if state.ResultsFocused {
1081+
// Jump to first match if cursor isn't on one
1082+
_, _, isFile := state.FlatItem(state.Cursor)
1083+
if isFile || state.Cursor == 0 {
1084+
state.Cursor = state.FirstMatchIndex()
1085+
}
1086+
}
1087+
p.clearProjectSearchModal() // Rebuild modal to reflect focus state
1088+
return p, cmd
1089+
}
1090+
1091+
// When results-focused, handle vim navigation keys before they reach
1092+
// the default printable-character handler.
1093+
if state != nil && state.ResultsFocused {
1094+
switch key {
1095+
case "j":
1096+
state.Cursor = state.NextMatchIndex()
1097+
return p, cmd
1098+
case "k":
1099+
state.Cursor = state.PrevMatchIndex()
1100+
return p, cmd
1101+
case "g":
1102+
state.Cursor = state.FirstMatchIndex()
1103+
state.ScrollOffset = 0
1104+
return p, cmd
1105+
case "G":
1106+
state.Cursor = state.LastMatchIndex()
1107+
return p, cmd
1108+
}
1109+
1110+
// Any other printable character switches back to input mode
1111+
// and falls through to the default handler below.
1112+
if len(key) == 1 && key[0] >= 32 && key[0] <= 126 {
1113+
state.ResultsFocused = false
1114+
p.clearProjectSearchModal()
1115+
}
1116+
}
1117+
10761118
switch key {
10771119
case "esc":
10781120
// Close project search
@@ -1106,25 +1148,30 @@ func (p *Plugin) handleProjectSearchKey(msg tea.KeyMsg) (plugin.Plugin, tea.Cmd)
11061148

11071149
case "down", "ctrl+n":
11081150
if state != nil {
1151+
// Arrow navigation auto-focuses results
1152+
state.ResultsFocused = true
11091153
// Skip file headers, only navigate between matches
11101154
state.Cursor = state.NextMatchIndex()
11111155
}
11121156

11131157
case "up", "ctrl+p":
11141158
if state != nil {
1159+
// Arrow navigation auto-focuses results
1160+
state.ResultsFocused = true
11151161
// Skip file headers, only navigate between matches
11161162
state.Cursor = state.PrevMatchIndex()
11171163
}
11181164

11191165
case "ctrl+g":
1120-
// Go to first match (ctrl+g to avoid conflict with typing 'g')
1166+
// Go to first match
11211167
if state != nil {
1168+
state.ResultsFocused = true
11221169
state.Cursor = state.FirstMatchIndex()
11231170
state.ScrollOffset = 0
11241171
}
11251172

11261173
case "ctrl+e":
1127-
// Open in editor at line (ctrl+e to avoid conflict with typing 'e')
1174+
// Open in editor at line
11281175
if state != nil && len(state.Results) > 0 {
11291176
path, lineNo := state.GetSelectedFile()
11301177
if path != "" {
@@ -1139,6 +1186,7 @@ func (p *Plugin) handleProjectSearchKey(msg tea.KeyMsg) (plugin.Plugin, tea.Cmd)
11391186
case "ctrl+d":
11401187
// Page down, snap to nearest match
11411188
if state != nil {
1189+
state.ResultsFocused = true
11421190
state.Cursor += 10
11431191
maxIdx := state.FlatLen() - 1
11441192
if state.Cursor > maxIdx {
@@ -1154,6 +1202,7 @@ func (p *Plugin) handleProjectSearchKey(msg tea.KeyMsg) (plugin.Plugin, tea.Cmd)
11541202
case "ctrl+u":
11551203
// Page up, snap to nearest match
11561204
if state != nil {
1205+
state.ResultsFocused = true
11571206
state.Cursor -= 10
11581207
if state.Cursor < 0 {
11591208
state.Cursor = 0
@@ -1176,6 +1225,8 @@ func (p *Plugin) handleProjectSearchKey(msg tea.KeyMsg) (plugin.Plugin, tea.Cmd)
11761225

11771226
case "backspace":
11781227
if state != nil && len(state.Query) > 0 {
1228+
state.ResultsFocused = false
1229+
p.clearProjectSearchModal()
11791230
runes := []rune(state.Query)
11801231
state.Query = string(runes[:len(runes)-1])
11811232
if state.Query == "" {

internal/plugins/filebrowser/plugin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ func (p *Plugin) Commands() []plugin.Command {
845845
{ID: "cancel", Name: "Cancel", Description: "Cancel quick open", Category: plugin.CategoryActions, Context: "file-browser-quick-open", Priority: 1},
846846
// Project search commands
847847
{ID: "select", Name: "Open", Description: "Open selected result", Category: plugin.CategoryActions, Context: "file-browser-project-search", Priority: 1},
848-
{ID: "toggle", Name: "Toggle", Description: "Expand/collapse file", Category: plugin.CategoryActions, Context: "file-browser-project-search", Priority: 2},
848+
{ID: "toggle", Name: "Focus", Description: "Toggle input/results focus (j/k/g/G in results)", Category: plugin.CategoryNavigation, Context: "file-browser-project-search", Priority: 2},
849849
{ID: "cancel", Name: "Close", Description: "Close search", Category: plugin.CategoryActions, Context: "file-browser-project-search", Priority: 3},
850850
// File operation commands (move/rename/create/delete)
851851
{ID: "confirm", Name: "Confirm", Description: "Confirm operation", Category: plugin.CategoryActions, Context: "file-browser-file-op", Priority: 1},

internal/plugins/filebrowser/project_search.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ type ProjectSearchState struct {
2828
WholeWord bool
2929

3030
// UI state
31-
Cursor int // Index in flattened results (files + matches)
32-
ScrollOffset int // For scrolling
33-
IsSearching bool // True while ripgrep is running
34-
Error string
31+
Cursor int // Index in flattened results (files + matches)
32+
ScrollOffset int // For scrolling
33+
IsSearching bool // True while ripgrep is running
34+
Error string
35+
ResultsFocused bool // When true, j/k/g/G navigate results instead of typing
3536

3637
// Debounce: only run search when version matches
3738
DebounceVersion int
@@ -152,6 +153,28 @@ func (s *ProjectSearchState) FirstMatchIndex() int {
152153
return 0 // Fallback to 0 if no matches visible
153154
}
154155

156+
// LastMatchIndex returns the flat index of the last visible match.
157+
// Skips file headers. Returns 0 if no matches are visible.
158+
func (s *ProjectSearchState) LastMatchIndex() int {
159+
last := 0
160+
found := false
161+
pos := 0
162+
for _, f := range s.Results {
163+
pos++ // Skip file header
164+
if !f.Collapsed && len(f.Matches) > 0 {
165+
found = true
166+
last = pos + len(f.Matches) - 1
167+
}
168+
if !f.Collapsed {
169+
pos += len(f.Matches)
170+
}
171+
}
172+
if !found {
173+
return 0
174+
}
175+
return last
176+
}
177+
155178
// NextMatchIndex returns the flat index of the next match after current cursor.
156179
// Skips file headers. Returns current cursor if no next match exists.
157180
func (s *ProjectSearchState) NextMatchIndex() int {

internal/plugins/filebrowser/project_search_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,89 @@ func TestProjectSearchState_PrevMatchIndex(t *testing.T) {
414414
}
415415
}
416416

417+
func TestProjectSearchState_LastMatchIndex(t *testing.T) {
418+
tests := []struct {
419+
name string
420+
results []SearchFileResult
421+
expected int
422+
}{
423+
{
424+
name: "empty results",
425+
results: nil,
426+
expected: 0,
427+
},
428+
{
429+
name: "single file with matches",
430+
results: []SearchFileResult{
431+
{Path: "a.go", Matches: []SearchMatch{{LineNo: 1}, {LineNo: 2}}},
432+
},
433+
expected: 2, // File header at 0, match1 at 1, match2 at 2
434+
},
435+
{
436+
name: "multiple files",
437+
results: []SearchFileResult{
438+
{Path: "a.go", Matches: []SearchMatch{{LineNo: 1}}},
439+
{Path: "b.go", Matches: []SearchMatch{{LineNo: 5}, {LineNo: 10}}},
440+
},
441+
expected: 4, // a.go(0), match(1), b.go(2), match(3), match(4)
442+
},
443+
{
444+
name: "last file collapsed",
445+
results: []SearchFileResult{
446+
{Path: "a.go", Matches: []SearchMatch{{LineNo: 1}, {LineNo: 2}}},
447+
{Path: "b.go", Collapsed: true, Matches: []SearchMatch{{LineNo: 5}}},
448+
},
449+
expected: 2, // Last visible match is a.go's second match at 2
450+
},
451+
{
452+
name: "all files collapsed",
453+
results: []SearchFileResult{
454+
{Path: "a.go", Collapsed: true, Matches: []SearchMatch{{LineNo: 1}}},
455+
{Path: "b.go", Collapsed: true, Matches: []SearchMatch{{LineNo: 5}}},
456+
},
457+
expected: 0, // No visible matches
458+
},
459+
{
460+
name: "single match",
461+
results: []SearchFileResult{
462+
{Path: "a.go", Matches: []SearchMatch{{LineNo: 1}}},
463+
},
464+
expected: 1, // File header at 0, single match at 1
465+
},
466+
}
467+
468+
for _, tc := range tests {
469+
t.Run(tc.name, func(t *testing.T) {
470+
state := NewProjectSearchState()
471+
state.Results = tc.results
472+
if got := state.LastMatchIndex(); got != tc.expected {
473+
t.Errorf("LastMatchIndex() = %d, want %d", got, tc.expected)
474+
}
475+
})
476+
}
477+
}
478+
479+
func TestProjectSearchState_ResultsFocused(t *testing.T) {
480+
t.Run("default is input focused", func(t *testing.T) {
481+
state := NewProjectSearchState()
482+
if state.ResultsFocused {
483+
t.Error("expected new state to have ResultsFocused=false")
484+
}
485+
})
486+
487+
t.Run("toggle results focused", func(t *testing.T) {
488+
state := NewProjectSearchState()
489+
state.ResultsFocused = true
490+
if !state.ResultsFocused {
491+
t.Error("expected ResultsFocused=true after setting")
492+
}
493+
state.ResultsFocused = false
494+
if state.ResultsFocused {
495+
t.Error("expected ResultsFocused=false after clearing")
496+
}
497+
})
498+
}
499+
417500
func TestProjectSearchState_NearestMatchIndex(t *testing.T) {
418501
state := NewProjectSearchState()
419502
state.Results = []SearchFileResult{

internal/plugins/filebrowser/project_search_view.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,11 @@ func (p *Plugin) projectSearchMaxVisible() int {
359359
// renderProjectSearchHeader renders the search input bar.
360360
func (p *Plugin) renderProjectSearchHeader(width int) string {
361361
state := p.projectSearchState
362+
// Show block cursor when input focused, thin cursor when results focused
362363
cursor := "█"
364+
if state.ResultsFocused {
365+
cursor = "▏"
366+
}
363367

364368
prefix := "Search: "
365369
available := width - len(prefix) - 1

0 commit comments

Comments
 (0)