detached 2e0d9a3

fix(picker): match a session name against its tab rows

A session with tabs is a group header, and only its tab rows are
destinations, so searching the session name matched nothing and Enter
kept the current row. Tab rows now carry their session name as a search
context field.
commit
2e0d9a36055dcde26a84c49cff58800430e4fd2c
author
brice · · 2026-09-23 02:57 UTC
parent
112960a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +59 -0
M internal/usecase/picker/model.go +14 -0
@@ -128,6 +128,9 @@ type row struct {
128128 line protocol.PickerLine
129129 foldedLabel string
130130 foldedDetail string
131+ // foldedContext is the owning session's name for a tab row, so searching a
132+ // session name reaches the tab rows that are its destinations.
133+ foldedContext string
131134 }
132135
133136 func (r row) key() string { return r.line.Key }
@@ -242,6 +245,17 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
242245 run = append(run, line)
243246 }
244247 flush()
248+ session := ""
249+ for i := range m.rows {
250+ switch m.rows[i].line.Kind {
251+ case protocol.PickerLineSession:
252+ session = m.rows[i].foldedLabel
253+ case protocol.PickerLineTab:
254+ m.rows[i].foldedContext = session
255+ default:
256+ session = ""
257+ }
258+ }
245259 m.searchMatches = nil
246260 m.matchRows = nil
247261 if m.searchActive {
@@ -128,6 +128,9 @@ type row struct {
128 line protocol.PickerLine128 line protocol.PickerLine
129 foldedLabel string129 foldedLabel string
130 foldedDetail string130 foldedDetail string
131+ // foldedContext is the owning session's name for a tab row, so searching a
132+ // session name reaches the tab rows that are its destinations.
133+ foldedContext string
131 }134 }
132 135
133 func (r row) key() string { return r.line.Key }136 func (r row) key() string { return r.line.Key }
@@ -242,6 +245,17 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
242 run = append(run, line)245 run = append(run, line)
243 }246 }
244 flush()247 flush()
248+ session := ""
249+ for i := range m.rows {
250+ switch m.rows[i].line.Kind {
251+ case protocol.PickerLineSession:
252+ session = m.rows[i].foldedLabel
253+ case protocol.PickerLineTab:
254+ m.rows[i].foldedContext = session
255+ default:
256+ session = ""
257+ }
258+ }
245 m.searchMatches = nil259 m.searchMatches = nil
246 m.matchRows = nil260 m.matchRows = nil
247 if m.searchActive {261 if m.searchActive {
M internal/usecase/picker/model_test.go +44 -0
@@ -371,6 +371,50 @@ func TestSearchMatchesLabelsAndDetails(t *testing.T) {
371371 require.False(t, m.SearchActive())
372372 }
373373
374+// TestSearchSessionNameReachesItsTabRows pins that a session with tabs, whose
375+// header is not itself a destination, is found by its name: the query matches
376+// the tab rows that are its destinations, never another session's tabs.
377+func TestSearchSessionNameReachesItsTabRows(t *testing.T) {
378+ tab := func(key, label string) protocol.PickerLine {
379+ return protocol.PickerLine{Key: key, Kind: protocol.PickerLineTab, Label: label, Focusable: true, Actions: protocol.PickerCanNavigate}
380+ }
381+ session := func(key, label string) protocol.PickerLine {
382+ return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label}
383+ }
384+ lines := []protocol.PickerLine{
385+ session("a", "picka1234"), tab("a/1", "1"),
386+ session("b", "pickb1234"), tab("b/1", "1"), tab("b/2", "logs"),
387+ }
388+ tests := []struct {
389+ name string
390+ query string
391+ wantMatches int
392+ wantKey string
393+ }{
394+ {name: "exact session name", query: "pickb1234", wantMatches: 2, wantKey: "b/1"},
395+ {name: "session name prefix", query: "picka", wantMatches: 1, wantKey: "a/1"},
396+ {name: "tab label still matches", query: "logs", wantMatches: 1, wantKey: "b/2"},
397+ {name: "no session of that name", query: "pickc", wantMatches: 0},
398+ }
399+ for _, tt := range tests {
400+ t.Run(tt.name, func(t *testing.T) {
401+ m := New(lines, Config{Intent: protocol.PickerIntentNavigation})
402+ m.EnterSearch()
403+ for _, r := range tt.query {
404+ m.InsertSearch(r)
405+ }
406+ require.Equal(t, tt.wantMatches, m.MatchCount())
407+ selected, ok := m.Selected()
408+ if tt.wantKey == "" {
409+ require.False(t, ok)
410+ return
411+ }
412+ require.True(t, ok)
413+ require.Equal(t, tt.wantKey, selected.Key)
414+ })
415+ }
416+}
417+
374418 func rowText(row []renderer.Cell) string {
375419 out := make([]rune, 0, len(row))
376420 for _, cell := range row {
@@ -371,6 +371,50 @@ func TestSearchMatchesLabelsAndDetails(t *testing.T) {
371 require.False(t, m.SearchActive())371 require.False(t, m.SearchActive())
372 }372 }
373 373
374+// TestSearchSessionNameReachesItsTabRows pins that a session with tabs, whose
375+// header is not itself a destination, is found by its name: the query matches
376+// the tab rows that are its destinations, never another session's tabs.
377+func TestSearchSessionNameReachesItsTabRows(t *testing.T) {
378+ tab := func(key, label string) protocol.PickerLine {
379+ return protocol.PickerLine{Key: key, Kind: protocol.PickerLineTab, Label: label, Focusable: true, Actions: protocol.PickerCanNavigate}
380+ }
381+ session := func(key, label string) protocol.PickerLine {
382+ return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label}
383+ }
384+ lines := []protocol.PickerLine{
385+ session("a", "picka1234"), tab("a/1", "1"),
386+ session("b", "pickb1234"), tab("b/1", "1"), tab("b/2", "logs"),
387+ }
388+ tests := []struct {
389+ name string
390+ query string
391+ wantMatches int
392+ wantKey string
393+ }{
394+ {name: "exact session name", query: "pickb1234", wantMatches: 2, wantKey: "b/1"},
395+ {name: "session name prefix", query: "picka", wantMatches: 1, wantKey: "a/1"},
396+ {name: "tab label still matches", query: "logs", wantMatches: 1, wantKey: "b/2"},
397+ {name: "no session of that name", query: "pickc", wantMatches: 0},
398+ }
399+ for _, tt := range tests {
400+ t.Run(tt.name, func(t *testing.T) {
401+ m := New(lines, Config{Intent: protocol.PickerIntentNavigation})
402+ m.EnterSearch()
403+ for _, r := range tt.query {
404+ m.InsertSearch(r)
405+ }
406+ require.Equal(t, tt.wantMatches, m.MatchCount())
407+ selected, ok := m.Selected()
408+ if tt.wantKey == "" {
409+ require.False(t, ok)
410+ return
411+ }
412+ require.True(t, ok)
413+ require.Equal(t, tt.wantKey, selected.Key)
414+ })
415+ }
416+}
417+
374 func rowText(row []renderer.Cell) string {418 func rowText(row []renderer.Cell) string {
375 out := make([]rune, 0, len(row))419 out := make([]rune, 0, len(row))
376 for _, cell := range row {420 for _, cell := range row {
M internal/usecase/picker/search.go +1 -0
@@ -264,6 +264,7 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
264264 }{
265265 {matchName, row.foldedLabel},
266266 {matchDetail, row.foldedDetail},
267+ {matchContext, row.foldedContext},
267268 }
268269
269270 var result searchMatch
@@ -264,6 +264,7 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
264 }{264 }{
265 {matchName, row.foldedLabel},265 {matchName, row.foldedLabel},
266 {matchDetail, row.foldedDetail},266 {matchDetail, row.foldedDetail},
267+ {matchContext, row.foldedContext},
267 }268 }
268 269
269 var result searchMatch270 var result searchMatch

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close