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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 128 | 128 | line protocol.PickerLine | |
| 129 | 129 | foldedLabel string | |
| 130 | 130 | 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 | 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 | 245 | run = append(run, line) | |
| 243 | 246 | } | |
| 244 | 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 | 259 | m.searchMatches = nil | |
| 246 | 260 | m.matchRows = nil | |
| 247 | 261 | if m.searchActive { | |
| @@ -128,6 +128,9 @@ type row struct { | |||||
| 128 | line protocol.PickerLine | 128 | line protocol.PickerLine | ||
| 129 | foldedLabel string | 129 | foldedLabel string | ||
| 130 | foldedDetail string | 130 | 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 = nil | 259 | m.searchMatches = nil | ||
| 246 | m.matchRows = nil | 260 | 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) { | |||
| 371 | 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 | 418 | func rowText(row []renderer.Cell) string { | |
| 375 | 419 | out := make([]rune, 0, len(row)) | |
| 376 | 420 | 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) { | |||
| 264 | 264 | }{ | |
| 265 | 265 | {matchName, row.foldedLabel}, | |
| 266 | 266 | {matchDetail, row.foldedDetail}, | |
| 267 | + | {matchContext, row.foldedContext}, | |
| 267 | 268 | } | |
| 268 | 269 | ||
| 269 | 270 | 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 searchMatch | 270 | var result searchMatch | ||