detached ef274b7

Merge pull request 'feat(picker): fold host sections and filter search to matches' (#22) from feat/picker-collapse-filter into main

Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/22
commit
ef274b72ea4cbe94a99a12d8cb5c0ebeb3ddab08
author
bnema · · 2026-10-08 19:26 UTC
merge of
a054d45 + c3b102a · diff against the first parent
signature
unsigned
6 files · +557 -128
M internal/usecase/client/picker_controller_test.go +10 -2
@@ -245,7 +245,15 @@ 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: "ss3 left climbs to the section header", chunk: []byte("\x1bOD"), wantNoSelection: true},
251+ {name: "right off a section keeps the row", chunk: []byte("\x1b[C"), wantCursor: "alpha"},
252+ {name: "left right fold and unfold", chunk: []byte("\x1b[D\x1b[D\x1bOC\x1b[B"), wantCursor: "alpha"},
253+ {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true},
254+ {name: "fold and unfold keep the list", chunk: []byte("hhl\x1b[B"), wantCursor: "alpha"},
255+ {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"},
256+ {name: "h and l are literal in search", chunk: []byte("/hl"), wantNoSelection: true, wantSearch: true, wantQuery: "hl"},
249257 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
250258 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},
251259 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
@@ -280,7 +288,7 @@ func TestPickerControllerInputRouting(t *testing.T) {
280288 }
281289 if tt.wantNoSelection {
282290 _, ok := controller.CursorKey()
283- require.False(t, ok, "the host row is a cursor destination but not a selection")
291+ require.False(t, ok, "the cursor row is not a selection")
284292 return
285293 }
286294 want := alphaKey
@@ -245,7 +245,15 @@ 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: "ss3 left climbs to the section header", chunk: []byte("\x1bOD"), wantNoSelection: true},
251+ {name: "right off a section keeps the row", chunk: []byte("\x1b[C"), wantCursor: "alpha"},
252+ {name: "left right fold and unfold", chunk: []byte("\x1b[D\x1b[D\x1bOC\x1b[B"), wantCursor: "alpha"},
253+ {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true},
254+ {name: "fold and unfold keep the list", chunk: []byte("hhl\x1b[B"), wantCursor: "alpha"},
255+ {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"},
256+ {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},257 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
250 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},258 {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}},259 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
@@ -280,7 +288,7 @@ func TestPickerControllerInputRouting(t *testing.T) {
280 }288 }
281 if tt.wantNoSelection {289 if tt.wantNoSelection {
282 _, ok := controller.CursorKey()290 _, ok := controller.CursorKey()
283- require.False(t, ok, "the host row is a cursor destination but not a selection")291+ require.False(t, ok, "the cursor row is not a selection")
284 return292 return
285 }293 }
286 want := alphaKey294 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 +272 -59
@@ -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.
@@ -154,10 +163,11 @@ type row struct {
154163 foldedContext string
155164 // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).
156165 tree string
166+ // sectionID names a section header locally: its label plus its occurrence
167+ // among same-label sections, so two hosts sharing a label fold apart.
168+ sectionID string
157169 }
158170
159-func (r row) key() string { return r.line.Key }
160-
161171 func (r row) kind() protocol.PickerLineKind { return r.line.Kind }
162172
163173 func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection }
@@ -167,11 +177,21 @@ func (r row) selectable() bool { return r.line.Actions != 0 }
167177 // focusable reports the source's authorisation to rest the cursor on this row.
168178 // A section header is never a destination; every other shape follows the
169179 // published flag, so a row the source kept for inspection stays reachable while
170-// a row it skipped is passed over.
180+// a row it skipped is passed over. The model lets the cursor rest on a section
181+// header on its own, only to fold it (see eligible).
171182 func (r row) focusable() bool {
172183 return r.line.Kind != protocol.PickerLineSection && r.line.Focusable
173184 }
174185
186+// id names the row for cursor restoration. A section has no source key, so
187+// it is named by its sectionID, which no source key can collide with.
188+func (r row) id() string {
189+ if r.section() {
190+ return r.sectionID
191+ }
192+ return r.line.Key
193+}
194+
175195 func (r row) rendersAsHeader() bool {
176196 return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost
177197 }
@@ -233,7 +253,7 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
233253 key, hadKey = cursor.Key, cursor.Key != ""
234254 }
235255 m.rebuild(key, hadKey, cursor.Index)
236- if hadKey {
256+ if hadKey && !m.hasRow(key) {
237257 if current, ok := m.cursorKey(); !ok || current != key {
238258 // The selected row left the publication (for example it was
239259 // killed): rest on the nearest surviving row above it instead of
@@ -243,12 +263,23 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
243263 }
244264 }
245265
246-// keysAboveCursor lists the eligible row keys above the cursor, nearest first.
266+// hasRow reports whether a row with this id is still published, drawn or not.
267+func (m *Model) hasRow(id string) bool {
268+ for _, r := range m.rows {
269+ if r.id() == id {
270+ return true
271+ }
272+ }
273+ return false
274+}
275+
276+// keysAboveCursor lists the row keys above the cursor, nearest first. Section
277+// headers are left out: a removed row never hands the cursor to a header.
247278 func (m *Model) keysAboveCursor() []string {
248279 var keys []string
249280 for i := m.selected - 1; i >= 0; i-- {
250- if m.eligible(i) {
251- keys = append(keys, m.rows[i].key())
281+ if m.target(i) {
282+ keys = append(keys, m.rows[i].id())
252283 }
253284 }
254285 return keys
@@ -259,7 +290,7 @@ func (m *Model) selectFirstKey(keys []string) {
259290 index := make(map[string]int, len(m.rows))
260291 for idx, candidate := range m.rows {
261292 if m.eligible(idx) {
262- index[candidate.key()] = idx
293+ index[candidate.id()] = idx
263294 }
264295 }
265296 for _, key := range keys {
@@ -299,10 +330,13 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
299330 }
300331 run = run[:0]
301332 }
333+ sections := make(map[string]int)
302334 for _, line := range m.lines {
303335 if line.Kind == protocol.PickerLineSection {
304336 flush()
305- m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label)})
337+ sectionID := fmt.Sprintf("\x00section:%d:%s", sections[line.Label], line.Label)
338+ sections[line.Label]++
339+ m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label), sectionID: sectionID})
306340 continue
307341 }
308342 run = append(run, line)
@@ -319,30 +353,159 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
319353 session = ""
320354 }
321355 }
322- m.assignTreePrefixes()
323356 m.searchMatches = nil
324357 m.matchRows = nil
325358 if m.searchActive {
326359 m.refreshSearch(false)
360+ } else {
361+ m.refreshView()
327362 }
328363 m.restoreSelection(key, hadKey, fallbackIndex)
329364 }
330365
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 │.
366+// refreshView recomputes which rows are drawn. A non-empty query shows only
367+// the matching rows plus their section and session headers, in tree order, and
368+// ignores folds. Otherwise every row is drawn except those under a collapsed
369+// section.
370+func (m *Model) refreshView() {
371+ hidden := make([]bool, len(m.rows))
372+ if m.searchRestricted() {
373+ section, session := -1, -1
374+ for i, r := range m.rows {
375+ hidden[i] = true
376+ switch r.kind() {
377+ case protocol.PickerLineSection:
378+ section, session = i, -1
379+ continue
380+ case protocol.PickerLineSession:
381+ session = i
382+ case protocol.PickerLineTab:
383+ default:
384+ session = -1
385+ }
386+ if _, ok := m.searchMatches[i]; !ok {
387+ continue
388+ }
389+ hidden[i] = false
390+ if section >= 0 {
391+ hidden[section] = false
392+ }
393+ if r.kind() == protocol.PickerLineTab && session >= 0 {
394+ hidden[session] = false
395+ }
396+ }
397+ } else {
398+ folded := false
399+ for i, r := range m.rows {
400+ if r.section() {
401+ folded = m.folded[r.sectionID]
402+ continue
403+ }
404+ hidden[i] = folded
405+ }
406+ }
407+ view := make([]int, 0, len(m.rows))
408+ for i := range m.rows {
409+ if !hidden[i] {
410+ view = append(view, i)
411+ }
412+ }
413+ m.hidden, m.view = hidden, view
414+ m.assignTreePrefixes()
415+}
416+
417+// sectionFolded reports whether section row i is drawn collapsed. A query
418+// shows matches regardless of folds, so it never draws a section collapsed.
419+func (m *Model) sectionFolded(i int) bool {
420+ return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].sectionID]
421+}
422+
423+// sectionOf reports the section header row i belongs to, or -1.
424+func (m *Model) sectionOf(i int) int {
425+ for j := i; j >= 0; j-- {
426+ if m.rows[j].section() {
427+ return j
428+ }
429+ }
430+ return -1
431+}
432+
433+// setFold collapses or expands the section under the cursor and reports
434+// whether anything changed. It never applies while a query filters the list.
435+func (m *Model) setFold(fold bool) bool {
436+ if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() {
437+ return false
438+ }
439+ label := m.rows[m.selected].sectionID
440+ if m.folded[label] == fold {
441+ return false
442+ }
443+ folded := make(map[string]bool, len(m.folded)+1)
444+ for k, v := range m.folded {
445+ folded[k] = v
446+ }
447+ if fold {
448+ folded[label] = true
449+ } else {
450+ delete(folded, label)
451+ }
452+ m.folded = folded
453+ m.refreshView()
454+ return true
455+}
456+
457+// CursorOnSection reports whether the cursor rests on a section header.
458+func (m *Model) CursorOnSection() bool {
459+ return m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].section()
460+}
461+
462+// ToggleSection collapses or expands the section header under the cursor and
463+// reports whether it did.
464+func (m *Model) ToggleSection() bool {
465+ if !m.CursorOnSection() {
466+ return false
467+ }
468+ return m.setFold(!m.folded[m.rows[m.selected].sectionID])
469+}
470+
471+// Left collapses the section under the cursor, or moves the cursor from a row
472+// up to its section header. It reports whether anything changed.
473+func (m *Model) Left() bool {
474+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
475+ return false
476+ }
477+ if m.rows[m.selected].section() {
478+ return m.setFold(true)
479+ }
480+ if section := m.sectionOf(m.selected); m.eligible(section) {
481+ m.selected = section
482+ return true
483+ }
484+ return false
485+}
486+
487+// Right expands the section under the cursor and reports whether it did.
488+func (m *Model) Right() bool {
489+ return m.setFold(false)
490+}
491+
492+// assignTreePrefixes gives each drawn row its tree branch: sessions and hosts
493+// hang off their section, tabs off their session. The last drawn sibling
494+// closes its branch with └─ and its children no longer continue the parent's │.
334495 func (m *Model) assignTreePrefixes() {
335- isHead := func(r row) bool { return r.rendersAsHeader() }
336496 parentLast := true
337- for i := range m.rows {
497+ for p, i := range m.view {
338498 r := &m.rows[i]
339499 switch {
340500 case r.section():
341501 r.tree = ""
342- case isHead(*r):
502+ case r.rendersAsHeader():
343503 last := true
344- for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
345- if isHead(m.rows[j]) {
504+ for _, j := range m.view[p+1:] {
505+ if m.rows[j].section() {
506+ break
507+ }
508+ if m.rows[j].rendersAsHeader() {
346509 last = false
347510 break
348511 }
@@ -350,7 +513,7 @@ func (m *Model) assignTreePrefixes() {
350513 parentLast = last
351514 r.tree = treeBranch(last)
352515 case r.kind() == protocol.PickerLineTab:
353- last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab
516+ last := p+1 >= len(m.view) || m.rows[m.view[p+1]].kind() != protocol.PickerLineTab
354517 stem := "│ "
355518 if parentLast {
356519 stem = " "
@@ -391,11 +554,20 @@ func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine {
391554 func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) {
392555 if hadKey {
393556 for idx, candidate := range m.rows {
394- if m.eligible(idx) && candidate.key() == key {
557+ if m.eligible(idx) && candidate.id() == key {
395558 m.selected = idx
396559 return
397560 }
398561 }
562+ // A row inside a collapsed section keeps the cursor on that section.
563+ for idx, candidate := range m.rows {
564+ if candidate.id() == key && idx < len(m.hidden) && m.hidden[idx] {
565+ if section := m.sectionOf(idx); m.eligible(section) {
566+ m.selected = section
567+ return
568+ }
569+ }
570+ }
399571 }
400572 m.selected = -1
401573 m.SelectNearestRow(fallbackIndex)
@@ -410,15 +582,36 @@ func (m *Model) searchRestricted() bool {
410582 return m != nil && m.searchActive && m.query.Value() != ""
411583 }
412584
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.
585+// eligible reports whether the cursor may rest on row i: the row is drawn,
586+// and either the source authorised it as a destination and the typed query,
587+// when there is one, matches it, or it is a section header outside a query.
415588 func (m *Model) eligible(i int) bool {
416- if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].focusable() {
589+ if m == nil || i < 0 || i >= len(m.rows) || i < len(m.hidden) && m.hidden[i] {
590+ return false
591+ }
592+ if m.rows[i].section() {
593+ return !m.searchRestricted()
594+ }
595+ if !m.rows[i].focusable() {
417596 return false
418597 }
419598 return !m.searchRestricted() || m.rowMatches(i)
420599 }
421600
601+// target reports whether the cursor may fall back to row i on its own: an
602+// eligible row that is not a section header.
603+func (m *Model) target(i int) bool {
604+ return m.eligible(i) && !m.rows[i].section()
605+}
606+
607+// restable is target, or eligible when section headers are allowed.
608+func (m *Model) restable(i int, sections bool) bool {
609+ if sections {
610+ return m.eligible(i)
611+ }
612+ return m.target(i)
613+}
614+
422615 // committable reports whether row i may be committed: it admits an action and
423616 // the typed query, when there is one, does not exclude it.
424617 func (m *Model) committable(i int) bool {
@@ -432,24 +625,12 @@ func (m *Model) cursorKey() (string, bool) {
432625 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
433626 return "", false
434627 }
435- return m.rows[m.selected].key(), true
628+ return m.rows[m.selected].id(), true
436629 }
437630
438-func (m *Model) Up() {
439- if m != nil && m.searchActive {
440- m.moveSearch(-1)
441- return
442- }
443- m.move(-1)
444-}
631+func (m *Model) Up() { m.move(-1) }
445632
446-func (m *Model) Down() {
447- if m != nil && m.searchActive {
448- m.moveSearch(1)
449- return
450- }
451- m.move(1)
452-}
633+func (m *Model) Down() { m.move(1) }
453634
454635 // Cursor reports the line under the picker cursor independently from whether
455636 // that row may be activated, so a refresh can preserve an anchor row.
@@ -481,22 +662,25 @@ func (m *Model) SelectedIndex() int {
481662 }
482663
483664 // SelectNearestRow selects the nearest eligible row at or after idx, falling
484-// back to the last eligible row before it.
665+// back to the last eligible row before it. Section headers are taken only when
666+// no other row qualifies.
485667 func (m *Model) SelectNearestRow(idx int) {
486668 if m == nil || len(m.rows) == 0 {
487669 return
488670 }
489671 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
672+ for _, sections := range [2]bool{false, true} {
673+ for i := idx; i < len(m.rows); i++ {
674+ if m.restable(i, sections) {
675+ m.selected = i
676+ return
677+ }
494678 }
495- }
496- for i := idx - 1; i >= 0; i-- {
497- if m.eligible(i) {
498- m.selected = i
499- return
679+ for i := idx - 1; i >= 0; i-- {
680+ if m.restable(i, sections) {
681+ m.selected = i
682+ return
683+ }
500684 }
501685 }
502686 }
@@ -552,10 +736,14 @@ func (m *Model) move(delta int) {
552736 }
553737 }
554738
739+// firstEligible reports the first row the cursor may rest on, preferring any
740+// row over a section header.
555741 func (m *Model) firstEligible() int {
556- for i := range m.rows {
557- if m.eligible(i) {
558- return i
742+ for _, sections := range [2]bool{false, true} {
743+ for i := range m.rows {
744+ if m.restable(i, sections) {
745+ return i
746+ }
559747 }
560748 }
561749 return -1
@@ -634,10 +822,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
634822 offset := m.scrollOffset(visible)
635823 clipX := rect.X + rect.Width
636824 for y := range visible {
637- idx := offset + y
638- if idx >= len(m.rows) {
825+ if offset+y >= len(m.view) {
639826 break
640827 }
828+ idx := m.view[offset+y]
641829 r := m.rows[idx]
642830 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
643831 if r.section() {
@@ -649,7 +837,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
649837 if r.line.Stopped && idx != m.selected {
650838 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
651839 }
652- if r.line.Dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) {
840+ if r.line.Dim && !(r.section() && idx == m.selected) {
653841 base.Attrs |= renderer.AttrDim
654842 nameStyle.Attrs |= renderer.AttrDim
655843 detailStyle.Attrs |= renderer.AttrDim
@@ -659,6 +847,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
659847 name := r.line.Label
660848 if r.section() {
661849 nameStyle.Bold = true
850+ marker := "▾ "
851+ if m.sectionFolded(idx) {
852+ marker = "▸ "
853+ name += fmt.Sprintf(" (%d)", m.sectionSessions(idx))
854+ }
855+ name = marker + name
662856 } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {
663857 nameStyle.Bold = true
664858 }
@@ -671,7 +865,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
671865 treeStyle.Attrs |= renderer.AttrDim
672866 } else {
673867 // Only the foreground comes from Tree: the row's fill and its
674- // dimming (a Dim line or a search miss) stay as they are.
868+ // dimming (a Dim line) stay as they are.
675869 treeStyle.Foreground = styles.Tree.Foreground
676870 treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB
677871 treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB
@@ -796,6 +990,13 @@ func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style rende
796990 if action == "" {
797991 enter = "Enter unavailable"
798992 }
993+ if m != nil && m.CursorOnSection() {
994+ enter = "Enter/h/l fold"
995+ if m.searchActive {
996+ // h and l type into the query while searching.
997+ enter = "Enter fold"
998+ }
999+ }
7991000 var groups []string
8001001 if m != nil && m.searchActive {
8011002 groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"}
@@ -844,15 +1045,27 @@ func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string {
8441045 return "open"
8451046 }
8461047
1048+// scrollOffset is the first drawn position so the cursor row stays visible.
8471049 func (m *Model) scrollOffset(visible int) int {
848- if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
1050+ if visible <= 0 || len(m.view) <= visible || m.selected < 0 {
8491051 return 0
8501052 }
851- if m.selected < visible {
1053+ position := slices.Index(m.view, m.selected)
1054+ if position < visible {
8521055 return 0
8531056 }
854- offset := m.selected - visible + 1
855- return min(offset, len(m.rows)-visible)
1057+ return min(position-visible+1, len(m.view)-visible)
1058+}
1059+
1060+// sectionSessions counts the session and host rows under section row i.
1061+func (m *Model) sectionSessions(i int) int {
1062+ count := 0
1063+ for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
1064+ if m.rows[j].rendersAsHeader() {
1065+ count++
1066+ }
1067+ }
1068+ return count
8561069 }
8571070
8581071 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.
@@ -154,10 +163,11 @@ type row struct {
154 foldedContext string163 foldedContext string
155 // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).164 // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).
156 tree string165 tree string
166+ // sectionID names a section header locally: its label plus its occurrence
167+ // among same-label sections, so two hosts sharing a label fold apart.
168+ sectionID string
157 }169 }
158 170
159-func (r row) key() string { return r.line.Key }
160-
161 func (r row) kind() protocol.PickerLineKind { return r.line.Kind }171 func (r row) kind() protocol.PickerLineKind { return r.line.Kind }
162 172
163 func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection }173 func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection }
@@ -167,11 +177,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.177 // 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 the178 // 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 while179 // published flag, so a row the source kept for inspection stays reachable while
170-// a row it skipped is passed over.180+// a row it skipped is passed over. The model lets the cursor rest on a section
181+// header on its own, only to fold it (see eligible).
171 func (r row) focusable() bool {182 func (r row) focusable() bool {
172 return r.line.Kind != protocol.PickerLineSection && r.line.Focusable183 return r.line.Kind != protocol.PickerLineSection && r.line.Focusable
173 }184 }
174 185
186+// id names the row for cursor restoration. A section has no source key, so
187+// it is named by its sectionID, which no source key can collide with.
188+func (r row) id() string {
189+ if r.section() {
190+ return r.sectionID
191+ }
192+ return r.line.Key
193+}
194+
175 func (r row) rendersAsHeader() bool {195 func (r row) rendersAsHeader() bool {
176 return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost196 return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost
177 }197 }
@@ -233,7 +253,7 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
233 key, hadKey = cursor.Key, cursor.Key != ""253 key, hadKey = cursor.Key, cursor.Key != ""
234 }254 }
235 m.rebuild(key, hadKey, cursor.Index)255 m.rebuild(key, hadKey, cursor.Index)
236- if hadKey {256+ if hadKey && !m.hasRow(key) {
237 if current, ok := m.cursorKey(); !ok || current != key {257 if current, ok := m.cursorKey(); !ok || current != key {
238 // The selected row left the publication (for example it was258 // The selected row left the publication (for example it was
239 // killed): rest on the nearest surviving row above it instead of259 // killed): rest on the nearest surviving row above it instead of
@@ -243,12 +263,23 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
243 }263 }
244 }264 }
245 265
246-// keysAboveCursor lists the eligible row keys above the cursor, nearest first.266+// hasRow reports whether a row with this id is still published, drawn or not.
267+func (m *Model) hasRow(id string) bool {
268+ for _, r := range m.rows {
269+ if r.id() == id {
270+ return true
271+ }
272+ }
273+ return false
274+}
275+
276+// keysAboveCursor lists the row keys above the cursor, nearest first. Section
277+// headers are left out: a removed row never hands the cursor to a header.
247 func (m *Model) keysAboveCursor() []string {278 func (m *Model) keysAboveCursor() []string {
248 var keys []string279 var keys []string
249 for i := m.selected - 1; i >= 0; i-- {280 for i := m.selected - 1; i >= 0; i-- {
250- if m.eligible(i) {281+ if m.target(i) {
251- keys = append(keys, m.rows[i].key())282+ keys = append(keys, m.rows[i].id())
252 }283 }
253 }284 }
254 return keys285 return keys
@@ -259,7 +290,7 @@ func (m *Model) selectFirstKey(keys []string) {
259 index := make(map[string]int, len(m.rows))290 index := make(map[string]int, len(m.rows))
260 for idx, candidate := range m.rows {291 for idx, candidate := range m.rows {
261 if m.eligible(idx) {292 if m.eligible(idx) {
262- index[candidate.key()] = idx293+ index[candidate.id()] = idx
263 }294 }
264 }295 }
265 for _, key := range keys {296 for _, key := range keys {
@@ -299,10 +330,13 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
299 }330 }
300 run = run[:0]331 run = run[:0]
301 }332 }
333+ sections := make(map[string]int)
302 for _, line := range m.lines {334 for _, line := range m.lines {
303 if line.Kind == protocol.PickerLineSection {335 if line.Kind == protocol.PickerLineSection {
304 flush()336 flush()
305- m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label)})337+ sectionID := fmt.Sprintf("\x00section:%d:%s", sections[line.Label], line.Label)
338+ sections[line.Label]++
339+ m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label), sectionID: sectionID})
306 continue340 continue
307 }341 }
308 run = append(run, line)342 run = append(run, line)
@@ -319,30 +353,159 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
319 session = ""353 session = ""
320 }354 }
321 }355 }
322- m.assignTreePrefixes()
323 m.searchMatches = nil356 m.searchMatches = nil
324 m.matchRows = nil357 m.matchRows = nil
325 if m.searchActive {358 if m.searchActive {
326 m.refreshSearch(false)359 m.refreshSearch(false)
360+ } else {
361+ m.refreshView()
327 }362 }
328 m.restoreSelection(key, hadKey, fallbackIndex)363 m.restoreSelection(key, hadKey, fallbackIndex)
329 }364 }
330 365
331-// assignTreePrefixes gives each row its tree branch: sessions and hosts hang366+// refreshView recomputes which rows are drawn. A non-empty query shows only
332-// off their section, tabs off their session. The last sibling closes its367+// 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 │.368+// ignores folds. Otherwise every row is drawn except those under a collapsed
369+// section.
370+func (m *Model) refreshView() {
371+ hidden := make([]bool, len(m.rows))
372+ if m.searchRestricted() {
373+ section, session := -1, -1
374+ for i, r := range m.rows {
375+ hidden[i] = true
376+ switch r.kind() {
377+ case protocol.PickerLineSection:
378+ section, session = i, -1
379+ continue
380+ case protocol.PickerLineSession:
381+ session = i
382+ case protocol.PickerLineTab:
383+ default:
384+ session = -1
385+ }
386+ if _, ok := m.searchMatches[i]; !ok {
387+ continue
388+ }
389+ hidden[i] = false
390+ if section >= 0 {
391+ hidden[section] = false
392+ }
393+ if r.kind() == protocol.PickerLineTab && session >= 0 {
394+ hidden[session] = false
395+ }
396+ }
397+ } else {
398+ folded := false
399+ for i, r := range m.rows {
400+ if r.section() {
401+ folded = m.folded[r.sectionID]
402+ continue
403+ }
404+ hidden[i] = folded
405+ }
406+ }
407+ view := make([]int, 0, len(m.rows))
408+ for i := range m.rows {
409+ if !hidden[i] {
410+ view = append(view, i)
411+ }
412+ }
413+ m.hidden, m.view = hidden, view
414+ m.assignTreePrefixes()
415+}
416+
417+// sectionFolded reports whether section row i is drawn collapsed. A query
418+// shows matches regardless of folds, so it never draws a section collapsed.
419+func (m *Model) sectionFolded(i int) bool {
420+ return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].sectionID]
421+}
422+
423+// sectionOf reports the section header row i belongs to, or -1.
424+func (m *Model) sectionOf(i int) int {
425+ for j := i; j >= 0; j-- {
426+ if m.rows[j].section() {
427+ return j
428+ }
429+ }
430+ return -1
431+}
432+
433+// setFold collapses or expands the section under the cursor and reports
434+// whether anything changed. It never applies while a query filters the list.
435+func (m *Model) setFold(fold bool) bool {
436+ if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() {
437+ return false
438+ }
439+ label := m.rows[m.selected].sectionID
440+ if m.folded[label] == fold {
441+ return false
442+ }
443+ folded := make(map[string]bool, len(m.folded)+1)
444+ for k, v := range m.folded {
445+ folded[k] = v
446+ }
447+ if fold {
448+ folded[label] = true
449+ } else {
450+ delete(folded, label)
451+ }
452+ m.folded = folded
453+ m.refreshView()
454+ return true
455+}
456+
457+// CursorOnSection reports whether the cursor rests on a section header.
458+func (m *Model) CursorOnSection() bool {
459+ return m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].section()
460+}
461+
462+// ToggleSection collapses or expands the section header under the cursor and
463+// reports whether it did.
464+func (m *Model) ToggleSection() bool {
465+ if !m.CursorOnSection() {
466+ return false
467+ }
468+ return m.setFold(!m.folded[m.rows[m.selected].sectionID])
469+}
470+
471+// Left collapses the section under the cursor, or moves the cursor from a row
472+// up to its section header. It reports whether anything changed.
473+func (m *Model) Left() bool {
474+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
475+ return false
476+ }
477+ if m.rows[m.selected].section() {
478+ return m.setFold(true)
479+ }
480+ if section := m.sectionOf(m.selected); m.eligible(section) {
481+ m.selected = section
482+ return true
483+ }
484+ return false
485+}
486+
487+// Right expands the section under the cursor and reports whether it did.
488+func (m *Model) Right() bool {
489+ return m.setFold(false)
490+}
491+
492+// assignTreePrefixes gives each drawn row its tree branch: sessions and hosts
493+// hang off their section, tabs off their session. The last drawn sibling
494+// closes its branch with └─ and its children no longer continue the parent's │.
334 func (m *Model) assignTreePrefixes() {495 func (m *Model) assignTreePrefixes() {
335- isHead := func(r row) bool { return r.rendersAsHeader() }
336 parentLast := true496 parentLast := true
337- for i := range m.rows {497+ for p, i := range m.view {
338 r := &m.rows[i]498 r := &m.rows[i]
339 switch {499 switch {
340 case r.section():500 case r.section():
341 r.tree = ""501 r.tree = ""
342- case isHead(*r):502+ case r.rendersAsHeader():
343 last := true503 last := true
344- for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {504+ for _, j := range m.view[p+1:] {
345- if isHead(m.rows[j]) {505+ if m.rows[j].section() {
506+ break
507+ }
508+ if m.rows[j].rendersAsHeader() {
346 last = false509 last = false
347 break510 break
348 }511 }
@@ -350,7 +513,7 @@ func (m *Model) assignTreePrefixes() {
350 parentLast = last513 parentLast = last
351 r.tree = treeBranch(last)514 r.tree = treeBranch(last)
352 case r.kind() == protocol.PickerLineTab:515 case r.kind() == protocol.PickerLineTab:
353- last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab516+ last := p+1 >= len(m.view) || m.rows[m.view[p+1]].kind() != protocol.PickerLineTab
354 stem := "│ "517 stem := "│ "
355 if parentLast {518 if parentLast {
356 stem = " "519 stem = " "
@@ -391,11 +554,20 @@ func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine {
391 func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) {554 func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) {
392 if hadKey {555 if hadKey {
393 for idx, candidate := range m.rows {556 for idx, candidate := range m.rows {
394- if m.eligible(idx) && candidate.key() == key {557+ if m.eligible(idx) && candidate.id() == key {
395 m.selected = idx558 m.selected = idx
396 return559 return
397 }560 }
398 }561 }
562+ // A row inside a collapsed section keeps the cursor on that section.
563+ for idx, candidate := range m.rows {
564+ if candidate.id() == key && idx < len(m.hidden) && m.hidden[idx] {
565+ if section := m.sectionOf(idx); m.eligible(section) {
566+ m.selected = section
567+ return
568+ }
569+ }
570+ }
399 }571 }
400 m.selected = -1572 m.selected = -1
401 m.SelectNearestRow(fallbackIndex)573 m.SelectNearestRow(fallbackIndex)
@@ -410,15 +582,36 @@ func (m *Model) searchRestricted() bool {
410 return m != nil && m.searchActive && m.query.Value() != ""582 return m != nil && m.searchActive && m.query.Value() != ""
411 }583 }
412 584
413-// eligible reports whether the cursor may rest on row i: the source authorised585+// 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.586+// and either the source authorised it as a destination and the typed query,
587+// when there is one, matches it, or it is a section header outside a query.
415 func (m *Model) eligible(i int) bool {588 func (m *Model) eligible(i int) bool {
416- if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].focusable() {589+ if m == nil || i < 0 || i >= len(m.rows) || i < len(m.hidden) && m.hidden[i] {
590+ return false
591+ }
592+ if m.rows[i].section() {
593+ return !m.searchRestricted()
594+ }
595+ if !m.rows[i].focusable() {
417 return false596 return false
418 }597 }
419 return !m.searchRestricted() || m.rowMatches(i)598 return !m.searchRestricted() || m.rowMatches(i)
420 }599 }
421 600
601+// target reports whether the cursor may fall back to row i on its own: an
602+// eligible row that is not a section header.
603+func (m *Model) target(i int) bool {
604+ return m.eligible(i) && !m.rows[i].section()
605+}
606+
607+// restable is target, or eligible when section headers are allowed.
608+func (m *Model) restable(i int, sections bool) bool {
609+ if sections {
610+ return m.eligible(i)
611+ }
612+ return m.target(i)
613+}
614+
422 // committable reports whether row i may be committed: it admits an action and615 // 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.616 // the typed query, when there is one, does not exclude it.
424 func (m *Model) committable(i int) bool {617 func (m *Model) committable(i int) bool {
@@ -432,24 +625,12 @@ func (m *Model) cursorKey() (string, bool) {
432 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {625 if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
433 return "", false626 return "", false
434 }627 }
435- return m.rows[m.selected].key(), true628+ return m.rows[m.selected].id(), true
436 }629 }
437 630
438-func (m *Model) Up() {631+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 632
446-func (m *Model) Down() {633+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 634
454 // Cursor reports the line under the picker cursor independently from whether635 // 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.636 // that row may be activated, so a refresh can preserve an anchor row.
@@ -481,22 +662,25 @@ func (m *Model) SelectedIndex() int {
481 }662 }
482 663
483 // SelectNearestRow selects the nearest eligible row at or after idx, falling664 // SelectNearestRow selects the nearest eligible row at or after idx, falling
484-// back to the last eligible row before it.665+// back to the last eligible row before it. Section headers are taken only when
666+// no other row qualifies.
485 func (m *Model) SelectNearestRow(idx int) {667 func (m *Model) SelectNearestRow(idx int) {
486 if m == nil || len(m.rows) == 0 {668 if m == nil || len(m.rows) == 0 {
487 return669 return
488 }670 }
489 idx = clamp(idx, 0, len(m.rows)-1)671 idx = clamp(idx, 0, len(m.rows)-1)
490- for i := idx; i < len(m.rows); i++ {672+ for _, sections := range [2]bool{false, true} {
491- if m.eligible(i) {673+ for i := idx; i < len(m.rows); i++ {
492- m.selected = i674+ if m.restable(i, sections) {
493- return675+ m.selected = i
676+ return
677+ }
494 }678 }
495- }679+ for i := idx - 1; i >= 0; i-- {
496- for i := idx - 1; i >= 0; i-- {680+ if m.restable(i, sections) {
497- if m.eligible(i) {681+ m.selected = i
498- m.selected = i682+ return
499- return683+ }
500 }684 }
501 }685 }
502 }686 }
@@ -552,10 +736,14 @@ func (m *Model) move(delta int) {
552 }736 }
553 }737 }
554 738
739+// firstEligible reports the first row the cursor may rest on, preferring any
740+// row over a section header.
555 func (m *Model) firstEligible() int {741 func (m *Model) firstEligible() int {
556- for i := range m.rows {742+ for _, sections := range [2]bool{false, true} {
557- if m.eligible(i) {743+ for i := range m.rows {
558- return i744+ if m.restable(i, sections) {
745+ return i
746+ }
559 }747 }
560 }748 }
561 return -1749 return -1
@@ -634,10 +822,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
634 offset := m.scrollOffset(visible)822 offset := m.scrollOffset(visible)
635 clipX := rect.X + rect.Width823 clipX := rect.X + rect.Width
636 for y := range visible {824 for y := range visible {
637- idx := offset + y825+ if offset+y >= len(m.view) {
638- if idx >= len(m.rows) {
639 break826 break
640 }827 }
828+ idx := m.view[offset+y]
641 r := m.rows[idx]829 r := m.rows[idx]
642 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail830 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
643 if r.section() {831 if r.section() {
@@ -649,7 +837,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
649 if r.line.Stopped && idx != m.selected {837 if r.line.Stopped && idx != m.selected {
650 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped838 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
651 }839 }
652- if r.line.Dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) {840+ if r.line.Dim && !(r.section() && idx == m.selected) {
653 base.Attrs |= renderer.AttrDim841 base.Attrs |= renderer.AttrDim
654 nameStyle.Attrs |= renderer.AttrDim842 nameStyle.Attrs |= renderer.AttrDim
655 detailStyle.Attrs |= renderer.AttrDim843 detailStyle.Attrs |= renderer.AttrDim
@@ -659,6 +847,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
659 name := r.line.Label847 name := r.line.Label
660 if r.section() {848 if r.section() {
661 nameStyle.Bold = true849 nameStyle.Bold = true
850+ marker := "▾ "
851+ if m.sectionFolded(idx) {
852+ marker = "▸ "
853+ name += fmt.Sprintf(" (%d)", m.sectionSessions(idx))
854+ }
855+ name = marker + name
662 } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {856 } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {
663 nameStyle.Bold = true857 nameStyle.Bold = true
664 }858 }
@@ -671,7 +865,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
671 treeStyle.Attrs |= renderer.AttrDim865 treeStyle.Attrs |= renderer.AttrDim
672 } else {866 } else {
673 // Only the foreground comes from Tree: the row's fill and its867 // Only the foreground comes from Tree: the row's fill and its
674- // dimming (a Dim line or a search miss) stay as they are.868+ // dimming (a Dim line) stay as they are.
675 treeStyle.Foreground = styles.Tree.Foreground869 treeStyle.Foreground = styles.Tree.Foreground
676 treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB870 treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB
677 treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB871 treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB
@@ -796,6 +990,13 @@ func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style rende
796 if action == "" {990 if action == "" {
797 enter = "Enter unavailable"991 enter = "Enter unavailable"
798 }992 }
993+ if m != nil && m.CursorOnSection() {
994+ enter = "Enter/h/l fold"
995+ if m.searchActive {
996+ // h and l type into the query while searching.
997+ enter = "Enter fold"
998+ }
999+ }
799 var groups []string1000 var groups []string
800 if m != nil && m.searchActive {1001 if m != nil && m.searchActive {
801 groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"}1002 groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"}
@@ -844,15 +1045,27 @@ func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string {
844 return "open"1045 return "open"
845 }1046 }
846 1047
1048+// scrollOffset is the first drawn position so the cursor row stays visible.
847 func (m *Model) scrollOffset(visible int) int {1049 func (m *Model) scrollOffset(visible int) int {
848- if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {1050+ if visible <= 0 || len(m.view) <= visible || m.selected < 0 {
849 return 01051 return 0
850 }1052 }
851- if m.selected < visible {1053+ position := slices.Index(m.view, m.selected)
1054+ if position < visible {
852 return 01055 return 0
853 }1056 }
854- offset := m.selected - visible + 11057+ return min(position-visible+1, len(m.view)-visible)
855- return min(offset, len(m.rows)-visible)1058+}
1059+
1060+// sectionSessions counts the session and host rows under section row i.
1061+func (m *Model) sectionSessions(i int) int {
1062+ count := 0
1063+ for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
1064+ if m.rows[j].rendersAsHeader() {
1065+ count++
1066+ }
1067+ }
1068+ return count
856 }1069 }
857 1070
858 func clamp(n, low, high int) int {1071 func clamp(n, low, high int) int {
M internal/usecase/picker/model_test.go +210 -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,213 @@ 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:0: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:0: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:0: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:0: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:0: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:0: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:0:LOCAL", key, "a match a fold hides leaves the cursor on its section")
707+}
708+
709+func TestRemovedRowNeverHandsTheCursorToASection(t *testing.T) {
710+ tests := []struct {
711+ name string
712+ cursor string
713+ drop string
714+ want string
715+ }{
716+ {name: "first tab under LOCAL", cursor: "w1", drop: "w1", want: "w2"},
717+ {name: "first tab under a remote section", cursor: "a1", drop: "a1", want: "s1"},
718+ }
719+ for _, tt := range tests {
720+ t.Run(tt.name, func(t *testing.T) {
721+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped, Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}})
722+ next := make([]protocol.PickerLine, 0)
723+ for _, line := range foldFixture() {
724+ if line.Key != tt.drop {
725+ next = append(next, line)
726+ }
727+ }
728+ m.ReplaceLines(next, protocol.PickerCursor{Index: -1})
729+ require.Equal(t, tt.want, mustSelectedKey(t, m))
730+ })
731+ }
732+}
733+
734+func TestSameLabelSectionsFoldApart(t *testing.T) {
735+ m := New([]protocol.PickerLine{
736+ section("remote"), navLine("a", "alpha"),
737+ section("remote"), navLine("b", "beta"),
738+ }, Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
739+ m.Left()
740+ m.Left()
741+ require.Equal(t, []string{"remote", "remote", "└─ beta"}, drawnRows(m))
742+}
743+
744+func TestClearSearchKeepsTheCursorOnADrawnRow(t *testing.T) {
745+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
746+ m.Left()
747+ m.Left()
748+ m.EnterSearch()
749+ m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL
750+ m.ClearSearch()
751+ require.False(t, m.hidden[m.SelectedIndex()], "the cursor rests on a drawn row")
752+}
753+
754+func TestSectionFooterHint(t *testing.T) {
755+ tests := []struct {
756+ name string
757+ search bool
758+ want string
759+ }{
760+ {name: "normal mode offers h/l", want: "Enter/h/l fold"},
761+ {name: "search mode types h/l", search: true, want: "Enter fold"},
762+ }
763+ for _, tt := range tests {
764+ t.Run(tt.name, func(t *testing.T) {
765+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
766+ m.Left()
767+ if tt.search {
768+ m.EnterSearch()
769+ }
770+ frame := m.Render(domain.Size{Cols: 80, Rows: 12}, Preview{})
771+ status := rowText(frame.Row(11))
772+ require.Contains(t, status, tt.want)
773+ if tt.search {
774+ require.NotContains(t, status, "h/l")
775+ }
776+ })
777+ }
778+}
@@ -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,213 @@ 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:0: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:0: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:0: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:0: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:0: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:0: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:0:LOCAL", key, "a match a fold hides leaves the cursor on its section")
707+}
708+
709+func TestRemovedRowNeverHandsTheCursorToASection(t *testing.T) {
710+ tests := []struct {
711+ name string
712+ cursor string
713+ drop string
714+ want string
715+ }{
716+ {name: "first tab under LOCAL", cursor: "w1", drop: "w1", want: "w2"},
717+ {name: "first tab under a remote section", cursor: "a1", drop: "a1", want: "s1"},
718+ }
719+ for _, tt := range tests {
720+ t.Run(tt.name, func(t *testing.T) {
721+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped, Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}})
722+ next := make([]protocol.PickerLine, 0)
723+ for _, line := range foldFixture() {
724+ if line.Key != tt.drop {
725+ next = append(next, line)
726+ }
727+ }
728+ m.ReplaceLines(next, protocol.PickerCursor{Index: -1})
729+ require.Equal(t, tt.want, mustSelectedKey(t, m))
730+ })
731+ }
732+}
733+
734+func TestSameLabelSectionsFoldApart(t *testing.T) {
735+ m := New([]protocol.PickerLine{
736+ section("remote"), navLine("a", "alpha"),
737+ section("remote"), navLine("b", "beta"),
738+ }, Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
739+ m.Left()
740+ m.Left()
741+ require.Equal(t, []string{"remote", "remote", "└─ beta"}, drawnRows(m))
742+}
743+
744+func TestClearSearchKeepsTheCursorOnADrawnRow(t *testing.T) {
745+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
746+ m.Left()
747+ m.Left()
748+ m.EnterSearch()
749+ m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL
750+ m.ClearSearch()
751+ require.False(t, m.hidden[m.SelectedIndex()], "the cursor rests on a drawn row")
752+}
753+
754+func TestSectionFooterHint(t *testing.T) {
755+ tests := []struct {
756+ name string
757+ search bool
758+ want string
759+ }{
760+ {name: "normal mode offers h/l", want: "Enter/h/l fold"},
761+ {name: "search mode types h/l", search: true, want: "Enter fold"},
762+ }
763+ for _, tt := range tests {
764+ t.Run(tt.name, func(t *testing.T) {
765+ m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
766+ m.Left()
767+ if tt.search {
768+ m.EnterSearch()
769+ }
770+ frame := m.Render(domain.Size{Cols: 80, Rows: 12}, Preview{})
771+ status := rowText(frame.Row(11))
772+ require.Contains(t, status, tt.want)
773+ if tt.search {
774+ require.NotContains(t, status, "h/l")
775+ }
776+ })
777+ }
778+}
M internal/usecase/picker/search.go +35 -65
@@ -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()
6158 }
6259
6360 func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
@@ -133,7 +130,7 @@ func (m *Model) ClearSearch() {
133130 return
134131 }
135132 m.query.SetValue("")
136- m.refreshSearch(false)
133+ m.refreshSearch(true)
137134 }
138135
139136 func (m *Model) MatchCount() int {
@@ -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 {
208- m.normalizeCursor(-1)
182+ m.refreshView()
183+ if selectFirst {
184+ m.normalizeCursor()
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,33 +197,39 @@ 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
241-// normalizeCursor places the cursor on a row the active query still shows. It
242-// keeps the current row when it qualifies, otherwise it takes the best match,
243-// then the nearest eligible row, and finally clears the cursor when nothing
244-// qualifies.
245-func (m *Model) normalizeCursor(best int) {
210+// normalizeCursor places the cursor on a drawn row. It keeps the current row
211+// when it qualifies; otherwise a query takes its first match, a fold hands the
212+// cursor to the collapsed section, and anything else takes the nearest
213+// eligible row. It clears the cursor when nothing qualifies.
214+func (m *Model) normalizeCursor() {
246215 if m == nil || m.eligible(m.selected) {
247216 return
248217 }
249- if best >= 0 {
250- m.selected = best
218+ if m.searchRestricted() {
219+ // The query shows nothing the cursor may rest on.
220+ m.selected = -1
221+ if len(m.matchRows) > 0 {
222+ m.selected = m.matchRows[0]
223+ }
251224 return
252225 }
226+ if m.selected >= 0 && m.selected < len(m.hidden) && m.hidden[m.selected] {
227+ // A row a collapsed section hides leaves the cursor on that section.
228+ if section := m.sectionOf(m.selected); m.eligible(section) {
229+ m.selected = section
230+ return
231+ }
232+ }
253233 hint := m.selected
254234 m.selected = -1
255235 m.SelectNearestRow(hint)
@@ -275,9 +255,6 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
275255 continue
276256 }
277257 result.fieldMatches = append(result.fieldMatches, match)
278- if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) {
279- result.best = match
280- }
281258 }
282259 return result, len(result.fieldMatches) != 0
283260 }
@@ -287,12 +264,5 @@ func scoreField(field matchField, text, query string, needle []rune) (fieldMatch
287264 if !ok {
288265 return fieldMatch{}, false
289266 }
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
267+ return fieldMatch{field: field, positions: matched.Positions}, true
298268 }
@@ -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()
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 }
@@ -133,7 +130,7 @@ func (m *Model) ClearSearch() {
133 return130 return
134 }131 }
135 m.query.SetValue("")132 m.query.SetValue("")
136- m.refreshSearch(false)133+ m.refreshSearch(true)
137 }134 }
138 135
139 func (m *Model) MatchCount() int {136 func (m *Model) MatchCount() int {
@@ -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()
208- m.normalizeCursor(-1)183+ if selectFirst {
184+ m.normalizeCursor()
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,33 +197,39 @@ 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 drawn row. It keeps the current row
242-// keeps the current row when it qualifies, otherwise it takes the best match,211+// when it qualifies; otherwise a query takes its first match, a fold hands the
243-// then the nearest eligible row, and finally clears the cursor when nothing212+// cursor to the collapsed section, and anything else takes the nearest
244-// qualifies.213+// eligible row. It clears the cursor when nothing qualifies.
245-func (m *Model) normalizeCursor(best int) {214+func (m *Model) normalizeCursor() {
246 if m == nil || m.eligible(m.selected) {215 if m == nil || m.eligible(m.selected) {
247 return216 return
248 }217 }
249- if best >= 0 {218+ if m.searchRestricted() {
250- m.selected = best219+ // The query shows nothing the cursor may rest on.
220+ m.selected = -1
221+ if len(m.matchRows) > 0 {
222+ m.selected = m.matchRows[0]
223+ }
251 return224 return
252 }225 }
226+ if m.selected >= 0 && m.selected < len(m.hidden) && m.hidden[m.selected] {
227+ // A row a collapsed section hides leaves the cursor on that section.
228+ if section := m.sectionOf(m.selected); m.eligible(section) {
229+ m.selected = section
230+ return
231+ }
232+ }
253 hint := m.selected233 hint := m.selected
254 m.selected = -1234 m.selected = -1
255 m.SelectNearestRow(hint)235 m.SelectNearestRow(hint)
@@ -275,9 +255,6 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
275 continue255 continue
276 }256 }
277 result.fieldMatches = append(result.fieldMatches, match)257 result.fieldMatches = append(result.fieldMatches, match)
278- if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) {
279- result.best = match
280- }
281 }258 }
282 return result, len(result.fieldMatches) != 0259 return result, len(result.fieldMatches) != 0
283 }260 }
@@ -287,12 +264,5 @@ func scoreField(field matchField, text, query string, needle []rune) (fieldMatch
287 if !ok {264 if !ok {
288 return fieldMatch{}, false265 return fieldMatch{}, false
289 }266 }
290- return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true267+ 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 }268 }

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