detached 1ccf6c7

feat(picker): fold host sections and filter search to matches

Section headers are cursor stops in the client picker: h/Left climbs to
the section and collapses it, l/Right expands it, Enter toggles it.
Folds are local presentation state kept across publications.

A search query now draws only matching rows with their section and
session headers, in tree order, and puts the cursor on the first match
instead of jumping to the best-scored row.
commit
1ccf6c758be7caa744fc3637c33b4ea5b3c9634c
author
brice · · 2026-10-08 17:19 UTC
parent
a054d45
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +444 -112
M internal/usecase/client/picker_controller_test.go +7 -2
@@ -245,7 +245,12 @@ func TestPickerControllerInputRouting(t *testing.T) {
245245 wantOp pickerOp
246246 }{
247247 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
248- {name: "up skips informational host row", chunk: []byte("\x1b[A"), wantCursor: "alpha"},
248+ {name: "up reaches the section header", chunk: []byte("\x1b[A"), wantNoSelection: true},
249+ {name: "left climbs to the section header", chunk: []byte("\x1b[D"), wantNoSelection: true},
250+ {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true},
251+ {name: "fold and unfold keep the list", chunk: []byte("hhl\x1b[B"), wantCursor: "alpha"},
252+ {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"},
253+ {name: "h and l are literal in search", chunk: []byte("/hl"), wantNoSelection: true, wantSearch: true, wantQuery: "hl"},
249254 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
250255 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},
251256 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
@@ -280,7 +285,7 @@ func TestPickerControllerInputRouting(t *testing.T) {
280285 }
281286 if tt.wantNoSelection {
282287 _, ok := controller.CursorKey()
283- require.False(t, ok, "the host row is a cursor destination but not a selection")
288+ require.False(t, ok, "the cursor row is not a selection")
284289 return
285290 }
286291 want := alphaKey
@@ -245,7 +245,12 @@ func TestPickerControllerInputRouting(t *testing.T) {
245 wantOp pickerOp245 wantOp pickerOp
246 }{246 }{
247 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},247 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
248- {name: "up skips informational host row", chunk: []byte("\x1b[A"), wantCursor: "alpha"},248+ {name: "up reaches the section header", chunk: []byte("\x1b[A"), wantNoSelection: true},
249+ {name: "left climbs to the section header", chunk: []byte("\x1b[D"), wantNoSelection: true},
250+ {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true},
251+ {name: "fold and unfold keep the list", chunk: []byte("hhl\x1b[B"), wantCursor: "alpha"},
252+ {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"},
253+ {name: "h and l are literal in search", chunk: []byte("/hl"), wantNoSelection: true, wantSearch: true, wantQuery: "hl"},
249 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},254 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
250 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},255 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},
251 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},256 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
@@ -280,7 +285,7 @@ func TestPickerControllerInputRouting(t *testing.T) {
280 }285 }
281 if tt.wantNoSelection {286 if tt.wantNoSelection {
282 _, ok := controller.CursorKey()287 _, ok := controller.CursorKey()
283- require.False(t, ok, "the host row is a cursor destination but not a selection")288+ require.False(t, ok, "the cursor row is not a selection")
284 return289 return
285 }290 }
286 want := alphaKey291 want := alphaKey
M internal/usecase/client/picker_input.go +6 -0
@@ -292,6 +292,12 @@ func decodeEscape(buf []byte, batch *pickerInputBatch) (consumed int, held []byt
292292 case 'B':
293293 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"})
294294 return 3, nil, false
295+ case 'C':
296+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Right"})
297+ return 3, nil, false
298+ case 'D':
299+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Left"})
300+ return 3, nil, false
295301 }
296302 // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is
297303 // consumed through its final byte and dropped.
@@ -292,6 +292,12 @@ func decodeEscape(buf []byte, batch *pickerInputBatch) (consumed int, held []byt
292 case 'B':292 case 'B':
293 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"})293 batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"})
294 return 3, nil, false294 return 3, nil, false
295+ case 'C':
296+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Right"})
297+ return 3, nil, false
298+ case 'D':
299+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Left"})
300+ return 3, nil, false
295 }301 }
296 // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is302 // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is
297 // consumed through its final byte and dropped.303 // consumed through its final byte and dropped.
M internal/usecase/client/picker_interaction.go +24 -1
@@ -523,7 +523,9 @@ type pickerOp struct {
523523
524524 // drive applies one ui-driver operation to an open loop. Keys
525525 // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are
526-// literal in search), `/` enters search, `s` reorders locally, `x` asks to
526+// literal in search), Left/Right and h/l fold the section header under the
527+// cursor (Left/h from a row first climbs to its section), Enter on a section
528+// header toggles it, `/` enters search, `s` reorders locally, `x` asks to
527529 // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape
528530 // exits search first), Backspace edits the query. Text runes insert only
529531 // while search is active; normal-mode typing is ignored exactly like the
@@ -558,7 +560,28 @@ func (loop *pickerLoop) drive(keys []string, text string) (op pickerOp, changed
558560 } else {
559561 loop.up()
560562 }
563+ case "Left":
564+ changed = loop.model.Left() || changed
565+ case "Right":
566+ changed = loop.model.Right() || changed
567+ case "h":
568+ if active {
569+ loop.insert('h')
570+ } else {
571+ changed = loop.model.Left() || changed
572+ }
573+ case "l":
574+ if active {
575+ loop.insert('l')
576+ } else {
577+ changed = loop.model.Right() || changed
578+ }
561579 case "Enter":
580+ // Enter on a section header folds it; it names no destination.
581+ if loop.model.CursorOnSection() {
582+ changed = loop.model.ToggleSection() || changed
583+ continue
584+ }
562585 // Commit is typed at the call site via selection; the
563586 // key itself sends zero bytes toward the PTY.
564587 op.commit = true
@@ -523,7 +523,9 @@ type pickerOp struct {
523 523
524 // drive applies one ui-driver operation to an open loop. Keys524 // drive applies one ui-driver operation to an open loop. Keys
525 // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are525 // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are
526-// literal in search), `/` enters search, `s` reorders locally, `x` asks to526+// literal in search), Left/Right and h/l fold the section header under the
527+// cursor (Left/h from a row first climbs to its section), Enter on a section
528+// header toggles it, `/` enters search, `s` reorders locally, `x` asks to
527 // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape529 // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape
528 // exits search first), Backspace edits the query. Text runes insert only530 // exits search first), Backspace edits the query. Text runes insert only
529 // while search is active; normal-mode typing is ignored exactly like the531 // while search is active; normal-mode typing is ignored exactly like the
@@ -558,7 +560,28 @@ func (loop *pickerLoop) drive(keys []string, text string) (op pickerOp, changed
558 } else {560 } else {
559 loop.up()561 loop.up()
560 }562 }
563+ case "Left":
564+ changed = loop.model.Left() || changed
565+ case "Right":
566+ changed = loop.model.Right() || changed
567+ case "h":
568+ if active {
569+ loop.insert('h')
570+ } else {
571+ changed = loop.model.Left() || changed
572+ }
573+ case "l":
574+ if active {
575+ loop.insert('l')
576+ } else {
577+ changed = loop.model.Right() || changed
578+ }
561 case "Enter":579 case "Enter":
580+ // Enter on a section header folds it; it names no destination.
581+ if loop.model.CursorOnSection() {
582+ changed = loop.model.ToggleSection() || changed
583+ continue
584+ }
562 // Commit is typed at the call site via selection; the585 // Commit is typed at the call site via selection; the
563 // key itself sends zero bytes toward the PTY.586 // key itself sends zero bytes toward the PTY.
564 op.commit = true587 op.commit = true
M internal/usecase/picker/model.go +238 -52
@@ -2,6 +2,7 @@ package picker
22
33 import (
44 "fmt"
5+ "slices"
56 "strings"
67
78 renderer "github.com/bnema/vev-vt"
@@ -142,6 +143,14 @@ type Model struct {
142143 query ui.TextInput
143144 searchMatches map[int]searchMatch
144145 matchRows []int
146+ // folded names the section headers the user collapsed. It is local
147+ // presentation state, kept across publications by section label.
148+ folded map[string]bool
149+ // hidden and view describe which rows are drawn: a collapsed section hides
150+ // its rows, and a non-empty query shows only matches and their ancestors.
151+ // Both are replaced, never mutated, so clones may share them.
152+ hidden []bool
153+ view []int
145154 }
146155
147156 // row is one rendered line plus its case-folded search fields.
@@ -167,11 +176,21 @@ func (r row) selectable() bool { return r.line.Actions != 0 }
167176 // focusable reports the source's authorisation to rest the cursor on this row.
168177 // A section header is never a destination; every other shape follows the
169178 // published flag, so a row the source kept for inspection stays reachable while
170-// a row it skipped is passed over.
179+// a row it skipped is passed over. The model lets the cursor rest on a section
180+// header on its own, only to fold it (see eligible).
171181 func (r row) focusable() bool {
172182 return r.line.Kind != protocol.PickerLineSection && r.line.Focusable
173183 }
174184
185+// id names the row for cursor restoration. A section has no source key, so
186+// it is named by its label in a namespace no source key can use.
187+func (r row) id() string {
188+ if r.section() {
189+ return "\x00section:" + r.line.Label
190+ }
191+ return r.line.Key
192+}
193+
175194 func (r row) rendersAsHeader() bool {
176195 return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost
177196 }
@@ -248,7 +267,7 @@ func (m *Model) keysAboveCursor() []string {
248267 var keys []string
249268 for i := m.selected - 1; i >= 0; i-- {
250269 if m.eligible(i) {
251- keys = append(keys, m.rows[i].key())
270+ keys = append(keys, m.rows[i].id())
252271 }
253272 }
254273 return keys
@@ -259,7 +278,7 @@ func (m *Model) selectFirstKey(keys []string) {
259278 index := make(map[string]int, len(m.rows))
260279 for idx, candidate := range m.rows {
261280 if m.eligible(idx) {
262- index[candidate.key()] = idx
281+ index[candidate.id()] = idx
263282 }
264283 }
265284 for _, key := range keys {
@@ -319,30 +338,159 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
319338 session = ""
320339 }
321340 }
322- m.assignTreePrefixes()
323341 m.searchMatches = nil
324342 m.matchRows = nil
325343 if m.searchActive {
326344 m.refreshSearch(false)
345+ } else {
346+ m.refreshView()
327347 }
328348 m.restoreSelection(key, hadKey, fallbackIndex)
329349 }
330350
331-// assignTreePrefixes gives each row its tree branch: sessions and hosts hang
332-// off their section, tabs off their session. The last sibling closes its
333-// branch with └─ and its children no longer continue the parent's │.
351+// refreshView recomputes which rows are drawn. A non-empty query shows only
352+// the matching rows plus their section and session headers, in tree order, and
353+// ignores folds. Otherwise every row is drawn except those under a collapsed
354+// section.
355+func (m *Model) refreshView() {
356+ hidden := make([]bool, len(m.rows))
357+ if m.searchRestricted() {
358+ section, session := -1, -1
359+ for i, r := range m.rows {
360+ hidden[i] = true
361+ switch r.kind() {
362+ case protocol.PickerLineSection:
363+ section, session = i, -1
364+ continue
365+ case protocol.PickerLineSession:
366+ session = i
367+ case protocol.PickerLineTab:
368+ default:
369+ session = -1
370+ }
371+ if _, ok := m.searchMatches[i]; !ok {
372+ continue
373+ }
374+ hidden[i] = false
375+ if section >= 0 {
376+ hidden[section] = false
377+ }
378+ if r.kind() == protocol.PickerLineTab && session >= 0 {
379+ hidden[session] = false
380+ }
381+ }
382+ } else {
383+ folded := false
384+ for i, r := range m.rows {
385+ if r.section() {
386+ folded = m.folded[r.line.Label]
387+ continue
388+ }
389+ hidden[i] = folded
390+ }
391+ }
392+ view := make([]int, 0, len(m.rows))
393+ for i := range m.rows {
394+ if !hidden[i] {
395+ view = append(view, i)
396+ }
397+ }
398+ m.hidden, m.view = hidden, view
399+ m.assignTreePrefixes()
400+}
401+
402+// sectionFolded reports whether section row i is drawn collapsed. A query
403+// shows matches regardless of folds, so it never draws a section collapsed.
404+func (m *Model) sectionFolded(i int) bool {
405+ return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].line.Label]
406+}
407+
408+// sectionOf reports the section header row i belongs to, or -1.
409+func (m *Model) sectionOf(i int) int {
410+ for j := i; j >= 0; j-- {
411+ if m.rows[j].section() {
412+ return j
413+ }
414+ }
415+ return -1
416+}
417+
418+// setFold collapses or expands the section under the cursor and reports
419+// whether anything changed. It never applies while a query filters the list.
420+func (m *Model) setFold(fold bool) bool {
421+ if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() {
422+ return false
423+ }
424+ label := m.rows[m.selected].line.Label
425+ if m.folded[label] == fold {
426+ return false
427+ }
428+ folded := make(map[string]bool, len(m.folded)+1)
429+ for k, v := range m.folded {
430+ folded[k] = v
431+ }
432+ if fold {
433+ folded[label] = true
434+ } else {
435+ delete(folded, label)
436+ }
437+ m.folded = folded
438+ m.refreshView()
439+ return true
440+}
441+
442+// CursorOnSection reports whether the cursor rests on a section header.
443+func (m *Model) CursorOnSection() bool {
444+ return m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].section()
445+}
446+
447+// ToggleSection collapses or expands the section header under the cursor and
448+// reports whether it did.
449+func (m *Model) ToggleSection() bool {
450+ if !m.CursorOnSection() {
451+ return false
452+ }
453+ return m.setFold(!m.folded[m.rows[m.selected].line.Label])
454+}
455+
456+// Left collapses the section under the cursor, or moves the cursor from a row
457+// up to its section header. It reports whether anything changed.
458+func (m *Model) Left() bool {
459+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
460+ return false
461+ }
462+ if m.rows[m.selected].section() {
463+ return m.setFold(true)
464+ }
465+ if section := m.sectionOf(m.selected); m.eligible(section) {
466+ m.selected = section
467+ return true
468+ }
469+ return false
470+}
471+
472+// Right expands the section under the cursor and reports whether it did.
473+func (m *Model) Right() bool {
474+ return m.setFold(false)
475+}
476+
477+// assignTreePrefixes gives each drawn row its tree branch: sessions and hosts
478+// hang off their section, tabs off their session. The last drawn sibling
479+// closes its branch with └─ and its children no longer continue the parent's │.
334480 func (m *Model) assignTreePrefixes() {
335- isHead := func(r row) bool { return r.rendersAsHeader() }
336481 parentLast := true
337- for i := range m.rows {
482+ for p, i := range m.view {
338483 r := &m.rows[i]
339484 switch {
340485 case r.section():
341486 r.tree = ""
342- case isHead(*r):
487+ case r.rendersAsHeader():
343488 last := true
344- for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
345- if isHead(m.rows[j]) {
489+ for _, j := range m.view[p+1:] {
490+ if m.rows[j].section() {
491+ break
492+ }
493+ if m.rows[j].rendersAsHeader() {
346494 last = false
347495 break
348496 }
@@ -350,7 +498,7 @@ func (m *Model) assignTreePrefixes() {
350498 parentLast = last
351499 r.tree = treeBranch(last)
352500 case r.kind() == protocol.PickerLineTab:
353- last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab
501+ last := p+1 >= len(m.view) || m.rows[m.view[p+1]].kind() != protocol.PickerLineTab
354502 stem := "│ "
355503 if parentLast {
356504 stem = " "
@@ -391,11 +539,20 @@ func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine {
391539 func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) {
392540 if hadKey {
393541 for idx, candidate := range m.rows {
394- if m.eligible(idx) && candidate.key() == key {
542+ if m.eligible(idx) && candidate.id() == key {
395543 m.selected = idx
396544 return
397545 }
398546 }
547+ // A row inside a collapsed section keeps the cursor on that section.
548+ for idx, candidate := range m.rows {
549+ if candidate.id() == key && idx < len(m.hidden) && m.hidden[idx] {
550+ if section := m.sectionOf(idx); m.eligible(section) {
551+ m.selected = section
552+ return
553+ }
554+ }
555+ }
399556 }
400557 m.selected = -1
401558 m.SelectNearestRow(fallbackIndex)
@@ -410,15 +567,28 @@ func (m *Model) searchRestricted() bool {
410567 return m != nil && m.searchActive && m.query.Value() != ""
411568 }
412569
413-// eligible reports whether the cursor may rest on row i: the source authorised
414-// the row as a destination and the typed query, when there is one, matches it.
570+// eligible reports whether the cursor may rest on row i: the row is drawn,
571+// and either the source authorised it as a destination and the typed query,
572+// when there is one, matches it, or it is a section header outside a query.
415573 func (m *Model) eligible(i int) bool {
416- if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].focusable() {
574+ if m == nil || i < 0 || i >= len(m.rows) || i < len(m.hidden) && m.hidden[i] {
575+ return false
576+ }
577+ if m.rows[i].section() {
578+ return !m.searchRestricted()
579+ }
580+ if !m.rows[i].focusable() {
417581 return false
418582 }
419583 return !m.searchRestricted() || m.rowMatches(i)
420584 }
421585
586+// target reports whether the cursor may fall back to row i on its own: an
587+// eligible row that is not a section header.
588+func (m *Model) target(i int) bool {
589+ return m.eligible(i) && !m.rows[i].section()
590+}
591+
422592 // committable reports whether row i may be committed: it admits an action and
423593 // the typed query, when there is one, does not exclude it.
424594 func (m *Model) committable(i int) bool {
@@ -432,24 +602,12 @@ func (m *Model) cursorKey() (string, bool) {
432602 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
433603 return "", false
434604 }
435- return m.rows[m.selected].key(), true
605+ return m.rows[m.selected].id(), true
436606 }
437607
438-func (m *Model) Up() {
439- if m != nil && m.searchActive {
440- m.moveSearch(-1)
441- return
442- }
443- m.move(-1)
444-}
608+func (m *Model) Up() { m.move(-1) }
445609
446-func (m *Model) Down() {
447- if m != nil && m.searchActive {
448- m.moveSearch(1)
449- return
450- }
451- m.move(1)
452-}
610+func (m *Model) Down() { m.move(1) }
453611
454612 // Cursor reports the line under the picker cursor independently from whether
455613 // that row may be activated, so a refresh can preserve an anchor row.
@@ -481,22 +639,25 @@ func (m *Model) SelectedIndex() int {
481639 }
482640
483641 // SelectNearestRow selects the nearest eligible row at or after idx, falling
484-// back to the last eligible row before it.
642+// back to the last eligible row before it. Section headers are taken only when
643+// no other row qualifies.
485644 func (m *Model) SelectNearestRow(idx int) {
486645 if m == nil || len(m.rows) == 0 {
487646 return
488647 }
489648 idx = clamp(idx, 0, len(m.rows)-1)
490- for i := idx; i < len(m.rows); i++ {
491- if m.eligible(i) {
492- m.selected = i
493- return
649+ for _, ok := range []func(int) bool{m.target, m.eligible} {
650+ for i := idx; i < len(m.rows); i++ {
651+ if ok(i) {
652+ m.selected = i
653+ return
654+ }
494655 }
495- }
496- for i := idx - 1; i >= 0; i-- {
497- if m.eligible(i) {
498- m.selected = i
499- return
656+ for i := idx - 1; i >= 0; i-- {
657+ if ok(i) {
658+ m.selected = i
659+ return
660+ }
500661 }
501662 }
502663 }
@@ -552,10 +713,14 @@ func (m *Model) move(delta int) {
552713 }
553714 }
554715
716+// firstEligible reports the first row the cursor may rest on, preferring any
717+// row over a section header.
555718 func (m *Model) firstEligible() int {
556- for i := range m.rows {
557- if m.eligible(i) {
558- return i
719+ for _, ok := range []func(int) bool{m.target, m.eligible} {
720+ for i := range m.rows {
721+ if ok(i) {
722+ return i
723+ }
559724 }
560725 }
561726 return -1
@@ -634,10 +799,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
634799 offset := m.scrollOffset(visible)
635800 clipX := rect.X + rect.Width
636801 for y := range visible {
637- idx := offset + y
638- if idx >= len(m.rows) {
802+ if offset+y >= len(m.view) {
639803 break
640804 }
805+ idx := m.view[offset+y]
641806 r := m.rows[idx]
642807 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
643808 if r.section() {
@@ -649,7 +814,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
649814 if r.line.Stopped && idx != m.selected {
650815 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
651816 }
652- if r.line.Dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) {
817+ if r.line.Dim && !(r.section() && idx == m.selected) {
653818 base.Attrs |= renderer.AttrDim
654819 nameStyle.Attrs |= renderer.AttrDim
655820 detailStyle.Attrs |= renderer.AttrDim
@@ -659,6 +824,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
659824 name := r.line.Label
660825 if r.section() {
661826 nameStyle.Bold = true
827+ marker := "▾ "
828+ if m.sectionFolded(idx) {
829+ marker = "▸ "
830+ name += fmt.Sprintf(" (%d)", m.sectionSessions(idx))
831+ }
832+ name = marker + name
662833 } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {
663834 nameStyle.Bold = true
664835 }
@@ -796,6 +967,9 @@ func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style rende
796967 if action == "" {
797968 enter = "Enter unavailable"
798969 }
970+ if m != nil && m.CursorOnSection() {
971+ enter = "Enter/h/l fold"
972+ }
799973 var groups []string
800974 if m != nil && m.searchActive {
801975 groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"}
@@ -844,15 +1018,27 @@ func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string {
8441018 return "open"
8451019 }
8461020
1021+// scrollOffset is the first drawn position so the cursor row stays visible.
8471022 func (m *Model) scrollOffset(visible int) int {
848- if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
1023+ if visible <= 0 || len(m.view) <= visible || m.selected < 0 {
8491024 return 0
8501025 }
851- if m.selected < visible {
1026+ position := slices.Index(m.view, m.selected)
1027+ if position < visible {
8521028 return 0
8531029 }
854- offset := m.selected - visible + 1
855- return min(offset, len(m.rows)-visible)
1030+ return min(position-visible+1, len(m.view)-visible)
1031+}
1032+
1033+// sectionSessions counts the session and host rows under section row i.
1034+func (m *Model) sectionSessions(i int) int {
1035+ count := 0
1036+ for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
1037+ if m.rows[j].rendersAsHeader() {
1038+ count++
1039+ }
1040+ }
1041+ return count
8561042 }
8571043
8581044 func clamp(n, low, high int) int {
@@ -2,6 +2,7 @@ package picker
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "slices"
5 "strings"6 "strings"
6 7
7 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
@@ -142,6 +143,14 @@ type Model struct {
142 query ui.TextInput143 query ui.TextInput
143 searchMatches map[int]searchMatch144 searchMatches map[int]searchMatch
144 matchRows []int145 matchRows []int
146+ // folded names the section headers the user collapsed. It is local
147+ // presentation state, kept across publications by section label.
148+ folded map[string]bool
149+ // hidden and view describe which rows are drawn: a collapsed section hides
150+ // its rows, and a non-empty query shows only matches and their ancestors.
151+ // Both are replaced, never mutated, so clones may share them.
152+ hidden []bool
153+ view []int
145 }154 }
146 155
147 // row is one rendered line plus its case-folded search fields.156 // row is one rendered line plus its case-folded search fields.
@@ -167,11 +176,21 @@ func (r row) selectable() bool { return r.line.Actions != 0 }
167 // focusable reports the source's authorisation to rest the cursor on this row.176 // focusable reports the source's authorisation to rest the cursor on this row.
168 // A section header is never a destination; every other shape follows the177 // A section header is never a destination; every other shape follows the
169 // published flag, so a row the source kept for inspection stays reachable while178 // published flag, so a row the source kept for inspection stays reachable while
170-// a row it skipped is passed over.179+// a row it skipped is passed over. The model lets the cursor rest on a section
180+// header on its own, only to fold it (see eligible).
171 func (r row) focusable() bool {181 func (r row) focusable() bool {
172 return r.line.Kind != protocol.PickerLineSection && r.line.Focusable182 return r.line.Kind != protocol.PickerLineSection && r.line.Focusable
173 }183 }
174 184
185+// id names the row for cursor restoration. A section has no source key, so
186+// it is named by its label in a namespace no source key can use.
187+func (r row) id() string {
188+ if r.section() {
189+ return "\x00section:" + r.line.Label
190+ }
191+ return r.line.Key
192+}
193+
175 func (r row) rendersAsHeader() bool {194 func (r row) rendersAsHeader() bool {
176 return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost195 return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost
177 }196 }
@@ -248,7 +267,7 @@ func (m *Model) keysAboveCursor() []string {
248 var keys []string267 var keys []string
249 for i := m.selected - 1; i >= 0; i-- {268 for i := m.selected - 1; i >= 0; i-- {
250 if m.eligible(i) {269 if m.eligible(i) {
251- keys = append(keys, m.rows[i].key())270+ keys = append(keys, m.rows[i].id())
252 }271 }
253 }272 }
254 return keys273 return keys
@@ -259,7 +278,7 @@ func (m *Model) selectFirstKey(keys []string) {
259 index := make(map[string]int, len(m.rows))278 index := make(map[string]int, len(m.rows))
260 for idx, candidate := range m.rows {279 for idx, candidate := range m.rows {
261 if m.eligible(idx) {280 if m.eligible(idx) {
262- index[candidate.key()] = idx281+ index[candidate.id()] = idx
263 }282 }
264 }283 }
265 for _, key := range keys {284 for _, key := range keys {
@@ -319,30 +338,159 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
319 session = ""338 session = ""
320 }339 }
321 }340 }
322- m.assignTreePrefixes()
323 m.searchMatches = nil341 m.searchMatches = nil
324 m.matchRows = nil342 m.matchRows = nil
325 if m.searchActive {343 if m.searchActive {
326 m.refreshSearch(false)344 m.refreshSearch(false)
345+ } else {
346+ m.refreshView()
327 }347 }
328 m.restoreSelection(key, hadKey, fallbackIndex)348 m.restoreSelection(key, hadKey, fallbackIndex)
329 }349 }
330 350
331-// assignTreePrefixes gives each row its tree branch: sessions and hosts hang351+// refreshView recomputes which rows are drawn. A non-empty query shows only
332-// off their section, tabs off their session. The last sibling closes its352+// the matching rows plus their section and session headers, in tree order, and
333-// branch with └─ and its children no longer continue the parent's │.353+// ignores folds. Otherwise every row is drawn except those under a collapsed
354+// section.
355+func (m *Model) refreshView() {
356+ hidden := make([]bool, len(m.rows))
357+ if m.searchRestricted() {
358+ section, session := -1, -1
359+ for i, r := range m.rows {
360+ hidden[i] = true
361+ switch r.kind() {
362+ case protocol.PickerLineSection:
363+ section, session = i, -1
364+ continue
365+ case protocol.PickerLineSession:
366+ session = i
367+ case protocol.PickerLineTab:
368+ default:
369+ session = -1
370+ }
371+ if _, ok := m.searchMatches[i]; !ok {
372+ continue
373+ }
374+ hidden[i] = false
375+ if section >= 0 {
376+ hidden[section] = false
377+ }
378+ if r.kind() == protocol.PickerLineTab && session >= 0 {
379+ hidden[session] = false
380+ }
381+ }
382+ } else {
383+ folded := false
384+ for i, r := range m.rows {
385+ if r.section() {
386+ folded = m.folded[r.line.Label]
387+ continue
388+ }
389+ hidden[i] = folded
390+ }
391+ }
392+ view := make([]int, 0, len(m.rows))
393+ for i := range m.rows {
394+ if !hidden[i] {
395+ view = append(view, i)
396+ }
397+ }
398+ m.hidden, m.view = hidden, view
399+ m.assignTreePrefixes()
400+}
401+
402+// sectionFolded reports whether section row i is drawn collapsed. A query
403+// shows matches regardless of folds, so it never draws a section collapsed.
404+func (m *Model) sectionFolded(i int) bool {
405+ return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].line.Label]
406+}
407+
408+// sectionOf reports the section header row i belongs to, or -1.
409+func (m *Model) sectionOf(i int) int {
410+ for j := i; j >= 0; j-- {
411+ if m.rows[j].section() {
412+ return j
413+ }
414+ }
415+ return -1
416+}
417+
418+// setFold collapses or expands the section under the cursor and reports
419+// whether anything changed. It never applies while a query filters the list.
420+func (m *Model) setFold(fold bool) bool {
421+ if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() {
422+ return false
423+ }
424+ label := m.rows[m.selected].line.Label
425+ if m.folded[label] == fold {
426+ return false
427+ }
428+ folded := make(map[string]bool, len(m.folded)+1)
429+ for k, v := range m.folded {
430+ folded[k] = v
431+ }
432+ if fold {
433+ folded[label] = true
434+ } else {
435+ delete(folded, label)
436+ }
437+ m.folded = folded
438+ m.refreshView()
439+ return true
440+}
441+
442+// CursorOnSection reports whether the cursor rests on a section header.
443+func (m *Model) CursorOnSection() bool {
444+ return m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].section()
445+}
446+
447+// ToggleSection collapses or expands the section header under the cursor and
448+// reports whether it did.
449+func (m *Model) ToggleSection() bool {
450+ if !m.CursorOnSection() {
451+ return false
452+ }
453+ return m.setFold(!m.folded[m.rows[m.selected].line.Label])
454+}
455+
456+// Left collapses the section under the cursor, or moves the cursor from a row
457+// up to its section header. It reports whether anything changed.
458+func (m *Model) Left() bool {
459+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
460+ return false
461+ }
462+ if m.rows[m.selected].section() {
463+ return m.setFold(true)
464+ }
465+ if section := m.sectionOf(m.selected); m.eligible(section) {
466+ m.selected = section
467+ return true
468+ }
469+ return false
470+}
471+
472+// Right expands the section under the cursor and reports whether it did.
473+func (m *Model) Right() bool {
474+ return m.setFold(false)
475+}
476+
477+// assignTreePrefixes gives each drawn row its tree branch: sessions and hosts
478+// hang off their section, tabs off their session. The last drawn sibling
479+// closes its branch with └─ and its children no longer continue the parent's │.
334 func (m *Model) assignTreePrefixes() {480 func (m *Model) assignTreePrefixes() {
335- isHead := func(r row) bool { return r.rendersAsHeader() }
336 parentLast := true481 parentLast := true
337- for i := range m.rows {482+ for p, i := range m.view {
338 r := &m.rows[i]483 r := &m.rows[i]
339 switch {484 switch {
340 case r.section():485 case r.section():
341 r.tree = ""486 r.tree = ""
342- case isHead(*r):487+ case r.rendersAsHeader():
343 last := true488 last := true
344- for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {489+ for _, j := range m.view[p+1:] {
345- if isHead(m.rows[j]) {490+ if m.rows[j].section() {
491+ break
492+ }
493+ if m.rows[j].rendersAsHeader() {
346 last = false494 last = false
347 break495 break
348 }496 }
@@ -350,7 +498,7 @@ func (m *Model) assignTreePrefixes() {
350 parentLast = last498 parentLast = last
351 r.tree = treeBranch(last)499 r.tree = treeBranch(last)
352 case r.kind() == protocol.PickerLineTab:500 case r.kind() == protocol.PickerLineTab:
353- last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab501+ last := p+1 >= len(m.view) || m.rows[m.view[p+1]].kind() != protocol.PickerLineTab
354 stem := "│ "502 stem := "│ "
355 if parentLast {503 if parentLast {
356 stem = " "504 stem = " "
@@ -391,11 +539,20 @@ func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine {
391 func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) {539 func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) {
392 if hadKey {540 if hadKey {
393 for idx, candidate := range m.rows {541 for idx, candidate := range m.rows {
394- if m.eligible(idx) && candidate.key() == key {542+ if m.eligible(idx) && candidate.id() == key {
395 m.selected = idx543 m.selected = idx
396 return544 return
397 }545 }
398 }546 }
547+ // A row inside a collapsed section keeps the cursor on that section.
548+ for idx, candidate := range m.rows {
549+ if candidate.id() == key && idx < len(m.hidden) && m.hidden[idx] {
550+ if section := m.sectionOf(idx); m.eligible(section) {
551+ m.selected = section
552+ return
553+ }
554+ }
555+ }
399 }556 }
400 m.selected = -1557 m.selected = -1
401 m.SelectNearestRow(fallbackIndex)558 m.SelectNearestRow(fallbackIndex)
@@ -410,15 +567,28 @@ func (m *Model) searchRestricted() bool {
410 return m != nil && m.searchActive && m.query.Value() != ""567 return m != nil && m.searchActive && m.query.Value() != ""
411 }568 }
412 569
413-// eligible reports whether the cursor may rest on row i: the source authorised570+// eligible reports whether the cursor may rest on row i: the row is drawn,
414-// the row as a destination and the typed query, when there is one, matches it.571+// and either the source authorised it as a destination and the typed query,
572+// when there is one, matches it, or it is a section header outside a query.
415 func (m *Model) eligible(i int) bool {573 func (m *Model) eligible(i int) bool {
416- if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].focusable() {574+ if m == nil || i < 0 || i >= len(m.rows) || i < len(m.hidden) && m.hidden[i] {
575+ return false
576+ }
577+ if m.rows[i].section() {
578+ return !m.searchRestricted()
579+ }
580+ if !m.rows[i].focusable() {
417 return false581 return false
418 }582 }
419 return !m.searchRestricted() || m.rowMatches(i)583 return !m.searchRestricted() || m.rowMatches(i)
420 }584 }
421 585
586+// target reports whether the cursor may fall back to row i on its own: an
587+// eligible row that is not a section header.
588+func (m *Model) target(i int) bool {
589+ return m.eligible(i) && !m.rows[i].section()
590+}
591+
422 // committable reports whether row i may be committed: it admits an action and592 // committable reports whether row i may be committed: it admits an action and
423 // the typed query, when there is one, does not exclude it.593 // the typed query, when there is one, does not exclude it.
424 func (m *Model) committable(i int) bool {594 func (m *Model) committable(i int) bool {
@@ -432,24 +602,12 @@ func (m *Model) cursorKey() (string, bool) {
432 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {602 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
433 return "", false603 return "", false
434 }604 }
435- return m.rows[m.selected].key(), true605+ return m.rows[m.selected].id(), true
436 }606 }
437 607
438-func (m *Model) Up() {608+func (m *Model) Up() { m.move(-1) }
439- if m != nil && m.searchActive {
440- m.moveSearch(-1)
441- return
442- }
443- m.move(-1)
444-}
445 609
446-func (m *Model) Down() {610+func (m *Model) Down() { m.move(1) }
447- if m != nil && m.searchActive {
448- m.moveSearch(1)
449- return
450- }
451- m.move(1)
452-}
453 611
454 // Cursor reports the line under the picker cursor independently from whether612 // Cursor reports the line under the picker cursor independently from whether
455 // that row may be activated, so a refresh can preserve an anchor row.613 // that row may be activated, so a refresh can preserve an anchor row.
@@ -481,22 +639,25 @@ func (m *Model) SelectedIndex() int {
481 }639 }
482 640
483 // SelectNearestRow selects the nearest eligible row at or after idx, falling641 // SelectNearestRow selects the nearest eligible row at or after idx, falling
484-// back to the last eligible row before it.642+// back to the last eligible row before it. Section headers are taken only when
643+// no other row qualifies.
485 func (m *Model) SelectNearestRow(idx int) {644 func (m *Model) SelectNearestRow(idx int) {
486 if m == nil || len(m.rows) == 0 {645 if m == nil || len(m.rows) == 0 {
487 return646 return
488 }647 }
489 idx = clamp(idx, 0, len(m.rows)-1)648 idx = clamp(idx, 0, len(m.rows)-1)
490- for i := idx; i < len(m.rows); i++ {649+ for _, ok := range []func(int) bool{m.target, m.eligible} {
491- if m.eligible(i) {650+ for i := idx; i < len(m.rows); i++ {
492- m.selected = i651+ if ok(i) {
493- return652+ m.selected = i
653+ return
654+ }
494 }655 }
495- }656+ for i := idx - 1; i >= 0; i-- {
496- for i := idx - 1; i >= 0; i-- {657+ if ok(i) {
497- if m.eligible(i) {658+ m.selected = i
498- m.selected = i659+ return
499- return660+ }
500 }661 }
501 }662 }
502 }663 }
@@ -552,10 +713,14 @@ func (m *Model) move(delta int) {
552 }713 }
553 }714 }
554 715
716+// firstEligible reports the first row the cursor may rest on, preferring any
717+// row over a section header.
555 func (m *Model) firstEligible() int {718 func (m *Model) firstEligible() int {
556- for i := range m.rows {719+ for _, ok := range []func(int) bool{m.target, m.eligible} {
557- if m.eligible(i) {720+ for i := range m.rows {
558- return i721+ if ok(i) {
722+ return i
723+ }
559 }724 }
560 }725 }
561 return -1726 return -1
@@ -634,10 +799,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
634 offset := m.scrollOffset(visible)799 offset := m.scrollOffset(visible)
635 clipX := rect.X + rect.Width800 clipX := rect.X + rect.Width
636 for y := range visible {801 for y := range visible {
637- idx := offset + y802+ if offset+y >= len(m.view) {
638- if idx >= len(m.rows) {
639 break803 break
640 }804 }
805+ idx := m.view[offset+y]
641 r := m.rows[idx]806 r := m.rows[idx]
642 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail807 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
643 if r.section() {808 if r.section() {
@@ -649,7 +814,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
649 if r.line.Stopped && idx != m.selected {814 if r.line.Stopped && idx != m.selected {
650 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped815 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
651 }816 }
652- if r.line.Dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) {817+ if r.line.Dim && !(r.section() && idx == m.selected) {
653 base.Attrs |= renderer.AttrDim818 base.Attrs |= renderer.AttrDim
654 nameStyle.Attrs |= renderer.AttrDim819 nameStyle.Attrs |= renderer.AttrDim
655 detailStyle.Attrs |= renderer.AttrDim820 detailStyle.Attrs |= renderer.AttrDim
@@ -659,6 +824,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
659 name := r.line.Label824 name := r.line.Label
660 if r.section() {825 if r.section() {
661 nameStyle.Bold = true826 nameStyle.Bold = true
827+ marker := "▾ "
828+ if m.sectionFolded(idx) {
829+ marker = "▸ "
830+ name += fmt.Sprintf(" (%d)", m.sectionSessions(idx))
831+ }
832+ name = marker + name
662 } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {833 } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {
663 nameStyle.Bold = true834 nameStyle.Bold = true
664 }835 }
@@ -796,6 +967,9 @@ func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style rende
796 if action == "" {967 if action == "" {
797 enter = "Enter unavailable"968 enter = "Enter unavailable"
798 }969 }
970+ if m != nil && m.CursorOnSection() {
971+ enter = "Enter/h/l fold"
972+ }
799 var groups []string973 var groups []string
800 if m != nil && m.searchActive {974 if m != nil && m.searchActive {
801 groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"}975 groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"}
@@ -844,15 +1018,27 @@ func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string {
844 return "open"1018 return "open"
845 }1019 }
846 1020
1021+// scrollOffset is the first drawn position so the cursor row stays visible.
847 func (m *Model) scrollOffset(visible int) int {1022 func (m *Model) scrollOffset(visible int) int {
848- if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {1023+ if visible <= 0 || len(m.view) <= visible || m.selected < 0 {
849 return 01024 return 0
850 }1025 }
851- if m.selected < visible {1026+ position := slices.Index(m.view, m.selected)
1027+ if position < visible {
852 return 01028 return 0
853 }1029 }
854- offset := m.selected - visible + 11030+ return min(position-visible+1, len(m.view)-visible)
855- return min(offset, len(m.rows)-visible)1031+}
1032+
1033+// sectionSessions counts the session and host rows under section row i.
1034+func (m *Model) sectionSessions(i int) int {
1035+ count := 0
1036+ for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
1037+ if m.rows[j].rendersAsHeader() {
1038+ count++
1039+ }
1040+ }
1041+ return count
856 }1042 }
857 1043
858 func clamp(n, low, high int) int {1044 func clamp(n, low, high int) int {
M internal/usecase/picker/model_test.go +139 -1
@@ -518,7 +518,6 @@ func TestTreeStyleKeepsRowDimming(t *testing.T) {
518518 dim.Dim = true
519519 return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim}
520520 }()},
521- {name: "search miss", lines: []protocol.PickerLine{section("local"), navLine("a", "alpha"), navLine("b", "beta")}, query: "alp"},
522521 }
523522 for _, tt := range tests {
524523 t.Run(tt.name, func(t *testing.T) {
@@ -567,3 +566,142 @@ func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) {
567566 require.True(t, ok)
568567 require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one")
569568 }
569+
570+// drawnRows reports the tree prefix and label of every drawn row.
571+func drawnRows(m *Model) []string {
572+ got := make([]string, 0, len(m.view))
573+ for _, i := range m.view {
574+ got = append(got, m.rows[i].tree+m.rows[i].line.Label)
575+ }
576+ return got
577+}
578+
579+func foldFixture() []protocol.PickerLine {
580+ session := func(key, label string) protocol.PickerLine {
581+ return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label}
582+ }
583+ return []protocol.PickerLine{
584+ section("LOCAL"),
585+ session("w", "work"), tabLine("w1", "shell", protocol.PickerCanNavigate), tabLine("w2", "logs", protocol.PickerCanNavigate),
586+ session("s", "scratch"), tabLine("s1", "apply-migrations", protocol.PickerCanNavigate),
587+ section("devbox"),
588+ session("a", "api"), tabLine("a1", "server", protocol.PickerCanNavigate), tabLine("a2", "db", protocol.PickerCanNavigate),
589+ }
590+}
591+
592+func TestSectionFolding(t *testing.T) {
593+ tests := []struct {
594+ name string
595+ keys func(m *Model)
596+ wantRows []string
597+ wantCursor string
598+ }{
599+ {
600+ name: "left from a tab climbs to its section",
601+ keys: func(m *Model) { m.Left() },
602+ wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
603+ wantCursor: "\x00section:LOCAL",
604+ },
605+ {
606+ name: "left on a section collapses it",
607+ keys: func(m *Model) { m.Left(); m.Left() },
608+ wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
609+ wantCursor: "\x00section:LOCAL",
610+ },
611+ {
612+ name: "down from a collapsed section skips its rows",
613+ keys: func(m *Model) { m.Left(); m.Left(); m.Down() },
614+ wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
615+ wantCursor: "\x00section:devbox",
616+ },
617+ {
618+ name: "right expands it again",
619+ keys: func(m *Model) { m.Left(); m.Left(); m.Right() },
620+ wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
621+ wantCursor: "\x00section:LOCAL",
622+ },
623+ {
624+ name: "toggle collapses and expands",
625+ keys: func(m *Model) { m.Left(); m.ToggleSection() },
626+ wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
627+ wantCursor: "\x00section:LOCAL",
628+ },
629+ {
630+ name: "right off a section does nothing",
631+ keys: func(m *Model) { m.Right() },
632+ wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
633+ wantCursor: "w1",
634+ },
635+ }
636+ for _, tt := range tests {
637+ t.Run(tt.name, func(t *testing.T) {
638+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
639+ require.Equal(t, "w1", cursorKey(t, m), "a section header never takes the initial cursor")
640+ tt.keys(m)
641+ require.Equal(t, tt.wantRows, drawnRows(m))
642+ key, _ := m.cursorKey()
643+ require.Equal(t, tt.wantCursor, key)
644+ })
645+ }
646+}
647+
648+func TestSectionFoldSurvivesRepublication(t *testing.T) {
649+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
650+ m.Left()
651+ m.Left()
652+ m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1})
653+ require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
654+ key, _ := m.cursorKey()
655+ require.Equal(t, "\x00section:LOCAL", key)
656+ _, ok := m.Selected()
657+ require.False(t, ok, "a section header is never committable")
658+
659+ frame := m.Render(domain.Size{Cols: 40, Rows: 10}, Preview{})
660+ require.True(t, strings.HasPrefix(rowText(frame.Row(0)), "▸ LOCAL (2)"), "collapsed header %q", rowText(frame.Row(0)))
661+ require.True(t, strings.HasPrefix(rowText(frame.Row(1)), "▾ devbox"), "expanded header %q", rowText(frame.Row(1)))
662+}
663+
664+func TestSearchShowsOnlyMatchesInTreeOrder(t *testing.T) {
665+ tests := []struct {
666+ name string
667+ query string
668+ wantRows []string
669+ wantCursor string
670+ }{
671+ {name: "matches keep their ancestors", query: "ap", wantRows: []string{"LOCAL", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "s1"},
672+ {name: "one tab match", query: "serv", wantRows: []string{"devbox", "└─ api", " └─ server"}, wantCursor: "a1"},
673+ {name: "no match draws nothing", query: "zzz", wantRows: []string{}},
674+ }
675+ for _, tt := range tests {
676+ t.Run(tt.name, func(t *testing.T) {
677+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
678+ m.Down()
679+ m.Down() // somewhere in the middle: typing still starts on the first match
680+ // A collapsed section never hides a match.
681+ m.Left()
682+ m.Left()
683+ m.EnterSearch()
684+ for _, r := range tt.query {
685+ m.InsertSearch(r)
686+ }
687+ require.Equal(t, tt.wantRows, drawnRows(m))
688+ if tt.wantCursor == "" {
689+ require.Equal(t, -1, m.SelectedIndex())
690+ return
691+ }
692+ require.Equal(t, tt.wantCursor, mustSelectedKey(t, m))
693+ })
694+ }
695+}
696+
697+func TestExitSearchRestoresFolds(t *testing.T) {
698+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
699+ m.Left()
700+ m.Left()
701+ m.EnterSearch()
702+ m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL
703+ m.ExitSearch()
704+ require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
705+ key, _ := m.cursorKey()
706+ require.Equal(t, "\x00section:LOCAL", key, "a match a fold hides leaves the cursor on its section")
707+}
@@ -518,7 +518,6 @@ func TestTreeStyleKeepsRowDimming(t *testing.T) {
518 dim.Dim = true518 dim.Dim = true
519 return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim}519 return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim}
520 }()},520 }()},
521- {name: "search miss", lines: []protocol.PickerLine{section("local"), navLine("a", "alpha"), navLine("b", "beta")}, query: "alp"},
522 }521 }
523 for _, tt := range tests {522 for _, tt := range tests {
524 t.Run(tt.name, func(t *testing.T) {523 t.Run(tt.name, func(t *testing.T) {
@@ -567,3 +566,142 @@ func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) {
567 require.True(t, ok)566 require.True(t, ok)
568 require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one")567 require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one")
569 }568 }
569+
570+// drawnRows reports the tree prefix and label of every drawn row.
571+func drawnRows(m *Model) []string {
572+ got := make([]string, 0, len(m.view))
573+ for _, i := range m.view {
574+ got = append(got, m.rows[i].tree+m.rows[i].line.Label)
575+ }
576+ return got
577+}
578+
579+func foldFixture() []protocol.PickerLine {
580+ session := func(key, label string) protocol.PickerLine {
581+ return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label}
582+ }
583+ return []protocol.PickerLine{
584+ section("LOCAL"),
585+ session("w", "work"), tabLine("w1", "shell", protocol.PickerCanNavigate), tabLine("w2", "logs", protocol.PickerCanNavigate),
586+ session("s", "scratch"), tabLine("s1", "apply-migrations", protocol.PickerCanNavigate),
587+ section("devbox"),
588+ session("a", "api"), tabLine("a1", "server", protocol.PickerCanNavigate), tabLine("a2", "db", protocol.PickerCanNavigate),
589+ }
590+}
591+
592+func TestSectionFolding(t *testing.T) {
593+ tests := []struct {
594+ name string
595+ keys func(m *Model)
596+ wantRows []string
597+ wantCursor string
598+ }{
599+ {
600+ name: "left from a tab climbs to its section",
601+ keys: func(m *Model) { m.Left() },
602+ wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
603+ wantCursor: "\x00section:LOCAL",
604+ },
605+ {
606+ name: "left on a section collapses it",
607+ keys: func(m *Model) { m.Left(); m.Left() },
608+ wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
609+ wantCursor: "\x00section:LOCAL",
610+ },
611+ {
612+ name: "down from a collapsed section skips its rows",
613+ keys: func(m *Model) { m.Left(); m.Left(); m.Down() },
614+ wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
615+ wantCursor: "\x00section:devbox",
616+ },
617+ {
618+ name: "right expands it again",
619+ keys: func(m *Model) { m.Left(); m.Left(); m.Right() },
620+ wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
621+ wantCursor: "\x00section:LOCAL",
622+ },
623+ {
624+ name: "toggle collapses and expands",
625+ keys: func(m *Model) { m.Left(); m.ToggleSection() },
626+ wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
627+ wantCursor: "\x00section:LOCAL",
628+ },
629+ {
630+ name: "right off a section does nothing",
631+ keys: func(m *Model) { m.Right() },
632+ wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
633+ wantCursor: "w1",
634+ },
635+ }
636+ for _, tt := range tests {
637+ t.Run(tt.name, func(t *testing.T) {
638+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
639+ require.Equal(t, "w1", cursorKey(t, m), "a section header never takes the initial cursor")
640+ tt.keys(m)
641+ require.Equal(t, tt.wantRows, drawnRows(m))
642+ key, _ := m.cursorKey()
643+ require.Equal(t, tt.wantCursor, key)
644+ })
645+ }
646+}
647+
648+func TestSectionFoldSurvivesRepublication(t *testing.T) {
649+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
650+ m.Left()
651+ m.Left()
652+ m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1})
653+ require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
654+ key, _ := m.cursorKey()
655+ require.Equal(t, "\x00section:LOCAL", key)
656+ _, ok := m.Selected()
657+ require.False(t, ok, "a section header is never committable")
658+
659+ frame := m.Render(domain.Size{Cols: 40, Rows: 10}, Preview{})
660+ require.True(t, strings.HasPrefix(rowText(frame.Row(0)), "▸ LOCAL (2)"), "collapsed header %q", rowText(frame.Row(0)))
661+ require.True(t, strings.HasPrefix(rowText(frame.Row(1)), "▾ devbox"), "expanded header %q", rowText(frame.Row(1)))
662+}
663+
664+func TestSearchShowsOnlyMatchesInTreeOrder(t *testing.T) {
665+ tests := []struct {
666+ name string
667+ query string
668+ wantRows []string
669+ wantCursor string
670+ }{
671+ {name: "matches keep their ancestors", query: "ap", wantRows: []string{"LOCAL", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "s1"},
672+ {name: "one tab match", query: "serv", wantRows: []string{"devbox", "└─ api", " └─ server"}, wantCursor: "a1"},
673+ {name: "no match draws nothing", query: "zzz", wantRows: []string{}},
674+ }
675+ for _, tt := range tests {
676+ t.Run(tt.name, func(t *testing.T) {
677+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
678+ m.Down()
679+ m.Down() // somewhere in the middle: typing still starts on the first match
680+ // A collapsed section never hides a match.
681+ m.Left()
682+ m.Left()
683+ m.EnterSearch()
684+ for _, r := range tt.query {
685+ m.InsertSearch(r)
686+ }
687+ require.Equal(t, tt.wantRows, drawnRows(m))
688+ if tt.wantCursor == "" {
689+ require.Equal(t, -1, m.SelectedIndex())
690+ return
691+ }
692+ require.Equal(t, tt.wantCursor, mustSelectedKey(t, m))
693+ })
694+ }
695+}
696+
697+func TestExitSearchRestoresFolds(t *testing.T) {
698+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
699+ m.Left()
700+ m.Left()
701+ m.EnterSearch()
702+ m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL
703+ m.ExitSearch()
704+ require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
705+ key, _ := m.cursorKey()
706+ require.Equal(t, "\x00section:LOCAL", key, "a match a fold hides leaves the cursor on its section")
707+}
M internal/usecase/picker/search.go +30 -56
@@ -1,7 +1,6 @@
11 package picker
22
33 import (
4- "slices"
54 "strings"
65 "unicode/utf8"
76
@@ -22,14 +21,10 @@ const (
2221 type fieldMatch struct {
2322 field matchField
2423 positions []int
25- rank int
26- span int
27- first int
2824 }
2925
3026 type searchMatch struct {
3127 fieldMatches []fieldMatch
32- best fieldMatch
3328 }
3429
3530 func (m searchMatch) positions(field matchField) []int {
@@ -58,6 +53,8 @@ func (m *Model) ExitSearch() {
5853 m.query.SetValue("")
5954 m.searchMatches = nil
6055 m.matchRows = nil
56+ m.refreshView()
57+ m.normalizeCursor(-1)
6158 }
6259
6360 func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
@@ -168,33 +165,11 @@ func (m *Model) rowMatches(idx int) bool {
168165 return ok
169166 }
170167
171-func (m *Model) moveSearch(delta int) {
172- if m == nil {
173- return
174- }
175- if len(m.matchRows) == 0 {
176- // The query shows nothing, so no row may hold the cursor.
177- m.selected = -1
178- return
179- }
180- position := slices.Index(m.matchRows, m.selected)
181- if position < 0 {
182- if delta > 0 {
183- m.selected = m.matchRows[0]
184- } else {
185- m.selected = m.matchRows[len(m.matchRows)-1]
186- }
187- return
188- }
189- next := position + delta
190- if next >= 0 && next < len(m.matchRows) {
191- m.selected = m.matchRows[next]
192- }
193-}
194-
195168 // refreshSearch recomputes the rows the active query shows and re-places the
196-// cursor: the editor never leaves it on a row the query hides.
197-func (m *Model) refreshSearch(selectBest bool) int {
169+// cursor: the editor never leaves it on a row the query hides. Matches keep
170+// the list's tree order, so typing narrows the list without reordering it;
171+// when the query changed (selectFirst) the cursor rests on the first match.
172+func (m *Model) refreshSearch(selectFirst bool) {
198173 m.searchMatches = make(map[int]searchMatch)
199174 m.matchRows = make([]int, 0, len(m.rows))
200175 query := strings.ToLower(m.query.Value())
@@ -204,15 +179,14 @@ func (m *Model) refreshSearch(selectBest bool) int {
204179 m.matchRows = append(m.matchRows, idx)
205180 }
206181 }
207- if selectBest {
182+ m.refreshView()
183+ if selectFirst {
208184 m.normalizeCursor(-1)
209185 }
210- return -1
186+ return
211187 }
212188
213189 needleRunes := []rune(query)
214- bestIdx := -1
215- var best fieldMatch
216190 for idx, row := range m.rows {
217191 if !row.focusable() {
218192 continue
@@ -223,19 +197,14 @@ func (m *Model) refreshSearch(selectBest bool) int {
223197 }
224198 m.searchMatches[idx] = matched
225199 m.matchRows = append(m.matchRows, idx)
226- if bestIdx < 0 || lessFieldMatch(matched.best, best) {
227- bestIdx, best = idx, matched.best
228- }
229200 }
230- if selectBest {
231- if bestIdx >= 0 {
232- m.selected = bestIdx
233- } else {
234- m.selected = -1
201+ m.refreshView()
202+ if selectFirst {
203+ m.selected = -1
204+ if len(m.matchRows) > 0 {
205+ m.selected = m.matchRows[0]
235206 }
236- m.normalizeCursor(bestIdx)
237207 }
238- return bestIdx
239208 }
240209
241210 // normalizeCursor places the cursor on a row the active query still shows. It
@@ -250,6 +219,21 @@ func (m *Model) normalizeCursor(best int) {
250219 m.selected = best
251220 return
252221 }
222+ if m.searchRestricted() {
223+ // The query shows nothing the cursor may rest on.
224+ m.selected = -1
225+ if len(m.matchRows) > 0 {
226+ m.selected = m.matchRows[0]
227+ }
228+ return
229+ }
230+ if m.selected >= 0 && m.selected < len(m.hidden) && m.hidden[m.selected] {
231+ // A row a collapsed section hides leaves the cursor on that section.
232+ if section := m.sectionOf(m.selected); m.eligible(section) {
233+ m.selected = section
234+ return
235+ }
236+ }
253237 hint := m.selected
254238 m.selected = -1
255239 m.SelectNearestRow(hint)
@@ -275,9 +259,6 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
275259 continue
276260 }
277261 result.fieldMatches = append(result.fieldMatches, match)
278- if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) {
279- result.best = match
280- }
281262 }
282263 return result, len(result.fieldMatches) != 0
283264 }
@@ -287,12 +268,5 @@ func scoreField(field matchField, text, query string, needle []rune) (fieldMatch
287268 if !ok {
288269 return fieldMatch{}, false
289270 }
290- return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true
291-}
292-
293-func lessFieldMatch(left, right fieldMatch) bool {
294- if left.rank != right.rank || left.span != right.span || left.first != right.first {
295- return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first})
296- }
297- return left.field < right.field
271+ return fieldMatch{field: field, positions: matched.Positions}, true
298272 }
@@ -1,7 +1,6 @@
1 package picker1 package picker
2 2
3 import (3 import (
4- "slices"
5 "strings"4 "strings"
6 "unicode/utf8"5 "unicode/utf8"
7 6
@@ -22,14 +21,10 @@ const (
22 type fieldMatch struct {21 type fieldMatch struct {
23 field matchField22 field matchField
24 positions []int23 positions []int
25- rank int
26- span int
27- first int
28 }24 }
29 25
30 type searchMatch struct {26 type searchMatch struct {
31 fieldMatches []fieldMatch27 fieldMatches []fieldMatch
32- best fieldMatch
33 }28 }
34 29
35 func (m searchMatch) positions(field matchField) []int {30 func (m searchMatch) positions(field matchField) []int {
@@ -58,6 +53,8 @@ func (m *Model) ExitSearch() {
58 m.query.SetValue("")53 m.query.SetValue("")
59 m.searchMatches = nil54 m.searchMatches = nil
60 m.matchRows = nil55 m.matchRows = nil
56+ m.refreshView()
57+ m.normalizeCursor(-1)
61 }58 }
62 59
63 func (m *Model) SearchActive() bool { return m != nil && m.searchActive }60 func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
@@ -168,33 +165,11 @@ func (m *Model) rowMatches(idx int) bool {
168 return ok165 return ok
169 }166 }
170 167
171-func (m *Model) moveSearch(delta int) {
172- if m == nil {
173- return
174- }
175- if len(m.matchRows) == 0 {
176- // The query shows nothing, so no row may hold the cursor.
177- m.selected = -1
178- return
179- }
180- position := slices.Index(m.matchRows, m.selected)
181- if position < 0 {
182- if delta > 0 {
183- m.selected = m.matchRows[0]
184- } else {
185- m.selected = m.matchRows[len(m.matchRows)-1]
186- }
187- return
188- }
189- next := position + delta
190- if next >= 0 && next < len(m.matchRows) {
191- m.selected = m.matchRows[next]
192- }
193-}
194-
195 // refreshSearch recomputes the rows the active query shows and re-places the168 // refreshSearch recomputes the rows the active query shows and re-places the
196-// cursor: the editor never leaves it on a row the query hides.169+// cursor: the editor never leaves it on a row the query hides. Matches keep
197-func (m *Model) refreshSearch(selectBest bool) int {170+// the list's tree order, so typing narrows the list without reordering it;
171+// when the query changed (selectFirst) the cursor rests on the first match.
172+func (m *Model) refreshSearch(selectFirst bool) {
198 m.searchMatches = make(map[int]searchMatch)173 m.searchMatches = make(map[int]searchMatch)
199 m.matchRows = make([]int, 0, len(m.rows))174 m.matchRows = make([]int, 0, len(m.rows))
200 query := strings.ToLower(m.query.Value())175 query := strings.ToLower(m.query.Value())
@@ -204,15 +179,14 @@ func (m *Model) refreshSearch(selectBest bool) int {
204 m.matchRows = append(m.matchRows, idx)179 m.matchRows = append(m.matchRows, idx)
205 }180 }
206 }181 }
207- if selectBest {182+ m.refreshView()
183+ if selectFirst {
208 m.normalizeCursor(-1)184 m.normalizeCursor(-1)
209 }185 }
210- return -1186+ return
211 }187 }
212 188
213 needleRunes := []rune(query)189 needleRunes := []rune(query)
214- bestIdx := -1
215- var best fieldMatch
216 for idx, row := range m.rows {190 for idx, row := range m.rows {
217 if !row.focusable() {191 if !row.focusable() {
218 continue192 continue
@@ -223,19 +197,14 @@ func (m *Model) refreshSearch(selectBest bool) int {
223 }197 }
224 m.searchMatches[idx] = matched198 m.searchMatches[idx] = matched
225 m.matchRows = append(m.matchRows, idx)199 m.matchRows = append(m.matchRows, idx)
226- if bestIdx < 0 || lessFieldMatch(matched.best, best) {
227- bestIdx, best = idx, matched.best
228- }
229 }200 }
230- if selectBest {201+ m.refreshView()
231- if bestIdx >= 0 {202+ if selectFirst {
232- m.selected = bestIdx203+ m.selected = -1
233- } else {204+ if len(m.matchRows) > 0 {
234- m.selected = -1205+ m.selected = m.matchRows[0]
235 }206 }
236- m.normalizeCursor(bestIdx)
237 }207 }
238- return bestIdx
239 }208 }
240 209
241 // normalizeCursor places the cursor on a row the active query still shows. It210 // normalizeCursor places the cursor on a row the active query still shows. It
@@ -250,6 +219,21 @@ func (m *Model) normalizeCursor(best int) {
250 m.selected = best219 m.selected = best
251 return220 return
252 }221 }
222+ if m.searchRestricted() {
223+ // The query shows nothing the cursor may rest on.
224+ m.selected = -1
225+ if len(m.matchRows) > 0 {
226+ m.selected = m.matchRows[0]
227+ }
228+ return
229+ }
230+ if m.selected >= 0 && m.selected < len(m.hidden) && m.hidden[m.selected] {
231+ // A row a collapsed section hides leaves the cursor on that section.
232+ if section := m.sectionOf(m.selected); m.eligible(section) {
233+ m.selected = section
234+ return
235+ }
236+ }
253 hint := m.selected237 hint := m.selected
254 m.selected = -1238 m.selected = -1
255 m.SelectNearestRow(hint)239 m.SelectNearestRow(hint)
@@ -275,9 +259,6 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
275 continue259 continue
276 }260 }
277 result.fieldMatches = append(result.fieldMatches, match)261 result.fieldMatches = append(result.fieldMatches, match)
278- if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) {
279- result.best = match
280- }
281 }262 }
282 return result, len(result.fieldMatches) != 0263 return result, len(result.fieldMatches) != 0
283 }264 }
@@ -287,12 +268,5 @@ func scoreField(field matchField, text, query string, needle []rune) (fieldMatch
287 if !ok {268 if !ok {
288 return fieldMatch{}, false269 return fieldMatch{}, false
289 }270 }
290- return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true271+ return fieldMatch{field: field, positions: matched.Positions}, true
291-}
292-
293-func lessFieldMatch(left, right fieldMatch) bool {
294- if left.rank != right.rank || left.span != right.span || left.first != right.first {
295- return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first})
296- }
297- return left.field < right.field
298 }272 }

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