detached c3b102a

fix(picker): keep section folds and cursor fallbacks consistent

Removed rows no longer hand the cursor to a section header, ClearSearch
re-places the cursor on a drawn row, same-label sections fold apart, and
the footer drops the h/l fold hint while searching. Drop the unused
best-match parameter and row.key helper.
commit
c3b102ace81315dc47624f41567d36b21d288288
author
brice · · 2026-10-08 19:17 UTC
parent
1ccf6c7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +134 -37
M internal/usecase/client/picker_controller_test.go +3 -0
@@ -247,6 +247,9 @@ func TestPickerControllerInputRouting(t *testing.T) {
247247 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
248248 {name: "up reaches the section header", chunk: []byte("\x1b[A"), wantNoSelection: true},
249249 {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"},
250253 {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true},
251254 {name: "fold and unfold keep the list", chunk: []byte("hhl\x1b[B"), wantCursor: "alpha"},
252255 {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"},
@@ -247,6 +247,9 @@ func TestPickerControllerInputRouting(t *testing.T) {
247 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},247 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
248 {name: "up reaches the section header", chunk: []byte("\x1b[A"), wantNoSelection: true},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},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"},
250 {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true},253 {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"},254 {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"},255 {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"},
M internal/usecase/picker/model.go +45 -18
@@ -163,10 +163,11 @@ type row struct {
163163 foldedContext string
164164 // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).
165165 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
166169 }
167170
168-func (r row) key() string { return r.line.Key }
169-
170171 func (r row) kind() protocol.PickerLineKind { return r.line.Kind }
171172
172173 func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection }
@@ -183,10 +184,10 @@ func (r row) focusable() bool {
183184 }
184185
185186 // 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+// it is named by its sectionID, which no source key can collide with.
187188 func (r row) id() string {
188189 if r.section() {
189- return "\x00section:" + r.line.Label
190+ return r.sectionID
190191 }
191192 return r.line.Key
192193 }
@@ -252,7 +253,7 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
252253 key, hadKey = cursor.Key, cursor.Key != ""
253254 }
254255 m.rebuild(key, hadKey, cursor.Index)
255- if hadKey {
256+ if hadKey && !m.hasRow(key) {
256257 if current, ok := m.cursorKey(); !ok || current != key {
257258 // The selected row left the publication (for example it was
258259 // killed): rest on the nearest surviving row above it instead of
@@ -262,11 +263,22 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
262263 }
263264 }
264265
265-// 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.
266278 func (m *Model) keysAboveCursor() []string {
267279 var keys []string
268280 for i := m.selected - 1; i >= 0; i-- {
269- if m.eligible(i) {
281+ if m.target(i) {
270282 keys = append(keys, m.rows[i].id())
271283 }
272284 }
@@ -318,10 +330,13 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
318330 }
319331 run = run[:0]
320332 }
333+ sections := make(map[string]int)
321334 for _, line := range m.lines {
322335 if line.Kind == protocol.PickerLineSection {
323336 flush()
324- 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})
325340 continue
326341 }
327342 run = append(run, line)
@@ -383,7 +398,7 @@ func (m *Model) refreshView() {
383398 folded := false
384399 for i, r := range m.rows {
385400 if r.section() {
386- folded = m.folded[r.line.Label]
401+ folded = m.folded[r.sectionID]
387402 continue
388403 }
389404 hidden[i] = folded
@@ -402,7 +417,7 @@ func (m *Model) refreshView() {
402417 // sectionFolded reports whether section row i is drawn collapsed. A query
403418 // shows matches regardless of folds, so it never draws a section collapsed.
404419 func (m *Model) sectionFolded(i int) bool {
405- return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].line.Label]
420+ return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].sectionID]
406421 }
407422
408423 // sectionOf reports the section header row i belongs to, or -1.
@@ -421,7 +436,7 @@ func (m *Model) setFold(fold bool) bool {
421436 if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() {
422437 return false
423438 }
424- label := m.rows[m.selected].line.Label
439+ label := m.rows[m.selected].sectionID
425440 if m.folded[label] == fold {
426441 return false
427442 }
@@ -450,7 +465,7 @@ func (m *Model) ToggleSection() bool {
450465 if !m.CursorOnSection() {
451466 return false
452467 }
453- return m.setFold(!m.folded[m.rows[m.selected].line.Label])
468+ return m.setFold(!m.folded[m.rows[m.selected].sectionID])
454469 }
455470
456471 // Left collapses the section under the cursor, or moves the cursor from a row
@@ -589,6 +604,14 @@ func (m *Model) target(i int) bool {
589604 return m.eligible(i) && !m.rows[i].section()
590605 }
591606
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+
592615 // committable reports whether row i may be committed: it admits an action and
593616 // the typed query, when there is one, does not exclude it.
594617 func (m *Model) committable(i int) bool {
@@ -646,15 +669,15 @@ func (m *Model) SelectNearestRow(idx int) {
646669 return
647670 }
648671 idx = clamp(idx, 0, len(m.rows)-1)
649- for _, ok := range []func(int) bool{m.target, m.eligible} {
672+ for _, sections := range [2]bool{false, true} {
650673 for i := idx; i < len(m.rows); i++ {
651- if ok(i) {
674+ if m.restable(i, sections) {
652675 m.selected = i
653676 return
654677 }
655678 }
656679 for i := idx - 1; i >= 0; i-- {
657- if ok(i) {
680+ if m.restable(i, sections) {
658681 m.selected = i
659682 return
660683 }
@@ -716,9 +739,9 @@ func (m *Model) move(delta int) {
716739 // firstEligible reports the first row the cursor may rest on, preferring any
717740 // row over a section header.
718741 func (m *Model) firstEligible() int {
719- for _, ok := range []func(int) bool{m.target, m.eligible} {
742+ for _, sections := range [2]bool{false, true} {
720743 for i := range m.rows {
721- if ok(i) {
744+ if m.restable(i, sections) {
722745 return i
723746 }
724747 }
@@ -842,7 +865,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
842865 treeStyle.Attrs |= renderer.AttrDim
843866 } else {
844867 // Only the foreground comes from Tree: the row's fill and its
845- // dimming (a Dim line or a search miss) stay as they are.
868+ // dimming (a Dim line) stay as they are.
846869 treeStyle.Foreground = styles.Tree.Foreground
847870 treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB
848871 treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB
@@ -969,6 +992,10 @@ func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style rende
969992 }
970993 if m != nil && m.CursorOnSection() {
971994 enter = "Enter/h/l fold"
995+ if m.searchActive {
996+ // h and l type into the query while searching.
997+ enter = "Enter fold"
998+ }
972999 }
9731000 var groups []string
9741001 if m != nil && m.searchActive {
@@ -163,10 +163,11 @@ type row struct {
163 foldedContext string163 foldedContext string
164 // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).164 // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).
165 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
166 }169 }
167 170
168-func (r row) key() string { return r.line.Key }
169-
170 func (r row) kind() protocol.PickerLineKind { return r.line.Kind }171 func (r row) kind() protocol.PickerLineKind { return r.line.Kind }
171 172
172 func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection }173 func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection }
@@ -183,10 +184,10 @@ func (r row) focusable() bool {
183 }184 }
184 185
185 // id names the row for cursor restoration. A section has no source key, so186 // 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+// it is named by its sectionID, which no source key can collide with.
187 func (r row) id() string {188 func (r row) id() string {
188 if r.section() {189 if r.section() {
189- return "\x00section:" + r.line.Label190+ return r.sectionID
190 }191 }
191 return r.line.Key192 return r.line.Key
192 }193 }
@@ -252,7 +253,7 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
252 key, hadKey = cursor.Key, cursor.Key != ""253 key, hadKey = cursor.Key, cursor.Key != ""
253 }254 }
254 m.rebuild(key, hadKey, cursor.Index)255 m.rebuild(key, hadKey, cursor.Index)
255- if hadKey {256+ if hadKey && !m.hasRow(key) {
256 if current, ok := m.cursorKey(); !ok || current != key {257 if current, ok := m.cursorKey(); !ok || current != key {
257 // The selected row left the publication (for example it was258 // The selected row left the publication (for example it was
258 // killed): rest on the nearest surviving row above it instead of259 // killed): rest on the nearest surviving row above it instead of
@@ -262,11 +263,22 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
262 }263 }
263 }264 }
264 265
265-// 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.
266 func (m *Model) keysAboveCursor() []string {278 func (m *Model) keysAboveCursor() []string {
267 var keys []string279 var keys []string
268 for i := m.selected - 1; i >= 0; i-- {280 for i := m.selected - 1; i >= 0; i-- {
269- if m.eligible(i) {281+ if m.target(i) {
270 keys = append(keys, m.rows[i].id())282 keys = append(keys, m.rows[i].id())
271 }283 }
272 }284 }
@@ -318,10 +330,13 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
318 }330 }
319 run = run[:0]331 run = run[:0]
320 }332 }
333+ sections := make(map[string]int)
321 for _, line := range m.lines {334 for _, line := range m.lines {
322 if line.Kind == protocol.PickerLineSection {335 if line.Kind == protocol.PickerLineSection {
323 flush()336 flush()
324- 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})
325 continue340 continue
326 }341 }
327 run = append(run, line)342 run = append(run, line)
@@ -383,7 +398,7 @@ func (m *Model) refreshView() {
383 folded := false398 folded := false
384 for i, r := range m.rows {399 for i, r := range m.rows {
385 if r.section() {400 if r.section() {
386- folded = m.folded[r.line.Label]401+ folded = m.folded[r.sectionID]
387 continue402 continue
388 }403 }
389 hidden[i] = folded404 hidden[i] = folded
@@ -402,7 +417,7 @@ func (m *Model) refreshView() {
402 // sectionFolded reports whether section row i is drawn collapsed. A query417 // sectionFolded reports whether section row i is drawn collapsed. A query
403 // shows matches regardless of folds, so it never draws a section collapsed.418 // shows matches regardless of folds, so it never draws a section collapsed.
404 func (m *Model) sectionFolded(i int) bool {419 func (m *Model) sectionFolded(i int) bool {
405- return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].line.Label]420+ return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].sectionID]
406 }421 }
407 422
408 // sectionOf reports the section header row i belongs to, or -1.423 // sectionOf reports the section header row i belongs to, or -1.
@@ -421,7 +436,7 @@ 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() {436 if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() {
422 return false437 return false
423 }438 }
424- label := m.rows[m.selected].line.Label439+ label := m.rows[m.selected].sectionID
425 if m.folded[label] == fold {440 if m.folded[label] == fold {
426 return false441 return false
427 }442 }
@@ -450,7 +465,7 @@ func (m *Model) ToggleSection() bool {
450 if !m.CursorOnSection() {465 if !m.CursorOnSection() {
451 return false466 return false
452 }467 }
453- return m.setFold(!m.folded[m.rows[m.selected].line.Label])468+ return m.setFold(!m.folded[m.rows[m.selected].sectionID])
454 }469 }
455 470
456 // Left collapses the section under the cursor, or moves the cursor from a row471 // Left collapses the section under the cursor, or moves the cursor from a row
@@ -589,6 +604,14 @@ func (m *Model) target(i int) bool {
589 return m.eligible(i) && !m.rows[i].section()604 return m.eligible(i) && !m.rows[i].section()
590 }605 }
591 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+
592 // 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
593 // the typed query, when there is one, does not exclude it.616 // the typed query, when there is one, does not exclude it.
594 func (m *Model) committable(i int) bool {617 func (m *Model) committable(i int) bool {
@@ -646,15 +669,15 @@ func (m *Model) SelectNearestRow(idx int) {
646 return669 return
647 }670 }
648 idx = clamp(idx, 0, len(m.rows)-1)671 idx = clamp(idx, 0, len(m.rows)-1)
649- for _, ok := range []func(int) bool{m.target, m.eligible} {672+ for _, sections := range [2]bool{false, true} {
650 for i := idx; i < len(m.rows); i++ {673 for i := idx; i < len(m.rows); i++ {
651- if ok(i) {674+ if m.restable(i, sections) {
652 m.selected = i675 m.selected = i
653 return676 return
654 }677 }
655 }678 }
656 for i := idx - 1; i >= 0; i-- {679 for i := idx - 1; i >= 0; i-- {
657- if ok(i) {680+ if m.restable(i, sections) {
658 m.selected = i681 m.selected = i
659 return682 return
660 }683 }
@@ -716,9 +739,9 @@ func (m *Model) move(delta int) {
716 // firstEligible reports the first row the cursor may rest on, preferring any739 // firstEligible reports the first row the cursor may rest on, preferring any
717 // row over a section header.740 // row over a section header.
718 func (m *Model) firstEligible() int {741 func (m *Model) firstEligible() int {
719- for _, ok := range []func(int) bool{m.target, m.eligible} {742+ for _, sections := range [2]bool{false, true} {
720 for i := range m.rows {743 for i := range m.rows {
721- if ok(i) {744+ if m.restable(i, sections) {
722 return i745 return i
723 }746 }
724 }747 }
@@ -842,7 +865,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
842 treeStyle.Attrs |= renderer.AttrDim865 treeStyle.Attrs |= renderer.AttrDim
843 } else {866 } else {
844 // Only the foreground comes from Tree: the row's fill and its867 // Only the foreground comes from Tree: the row's fill and its
845- // dimming (a Dim line or a search miss) stay as they are.868+ // dimming (a Dim line) stay as they are.
846 treeStyle.Foreground = styles.Tree.Foreground869 treeStyle.Foreground = styles.Tree.Foreground
847 treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB870 treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB
848 treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB871 treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB
@@ -969,6 +992,10 @@ func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style rende
969 }992 }
970 if m != nil && m.CursorOnSection() {993 if m != nil && m.CursorOnSection() {
971 enter = "Enter/h/l fold"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+ }
972 }999 }
973 var groups []string1000 var groups []string
974 if m != nil && m.searchActive {1001 if m != nil && m.searchActive {
M internal/usecase/picker/model_test.go +78 -7
@@ -600,31 +600,31 @@ func TestSectionFolding(t *testing.T) {
600600 name: "left from a tab climbs to its section",
601601 keys: func(m *Model) { m.Left() },
602602 wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
603- wantCursor: "\x00section:LOCAL",
603+ wantCursor: "\x00section:0:LOCAL",
604604 },
605605 {
606606 name: "left on a section collapses it",
607607 keys: func(m *Model) { m.Left(); m.Left() },
608608 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
609- wantCursor: "\x00section:LOCAL",
609+ wantCursor: "\x00section:0:LOCAL",
610610 },
611611 {
612612 name: "down from a collapsed section skips its rows",
613613 keys: func(m *Model) { m.Left(); m.Left(); m.Down() },
614614 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
615- wantCursor: "\x00section:devbox",
615+ wantCursor: "\x00section:0:devbox",
616616 },
617617 {
618618 name: "right expands it again",
619619 keys: func(m *Model) { m.Left(); m.Left(); m.Right() },
620620 wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
621- wantCursor: "\x00section:LOCAL",
621+ wantCursor: "\x00section:0:LOCAL",
622622 },
623623 {
624624 name: "toggle collapses and expands",
625625 keys: func(m *Model) { m.Left(); m.ToggleSection() },
626626 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
627- wantCursor: "\x00section:LOCAL",
627+ wantCursor: "\x00section:0:LOCAL",
628628 },
629629 {
630630 name: "right off a section does nothing",
@@ -652,7 +652,7 @@ func TestSectionFoldSurvivesRepublication(t *testing.T) {
652652 m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1})
653653 require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
654654 key, _ := m.cursorKey()
655- require.Equal(t, "\x00section:LOCAL", key)
655+ require.Equal(t, "\x00section:0:LOCAL", key)
656656 _, ok := m.Selected()
657657 require.False(t, ok, "a section header is never committable")
658658
@@ -703,5 +703,76 @@ func TestExitSearchRestoresFolds(t *testing.T) {
703703 m.ExitSearch()
704704 require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
705705 key, _ := m.cursorKey()
706- require.Equal(t, "\x00section:LOCAL", key, "a match a fold hides leaves the cursor on its section")
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+ }
707778 }
@@ -600,31 +600,31 @@ func TestSectionFolding(t *testing.T) {
600 name: "left from a tab climbs to its section",600 name: "left from a tab climbs to its section",
601 keys: func(m *Model) { m.Left() },601 keys: func(m *Model) { m.Left() },
602 wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},602 wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
603- wantCursor: "\x00section:LOCAL",603+ wantCursor: "\x00section:0:LOCAL",
604 },604 },
605 {605 {
606 name: "left on a section collapses it",606 name: "left on a section collapses it",
607 keys: func(m *Model) { m.Left(); m.Left() },607 keys: func(m *Model) { m.Left(); m.Left() },
608 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},608 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
609- wantCursor: "\x00section:LOCAL",609+ wantCursor: "\x00section:0:LOCAL",
610 },610 },
611 {611 {
612 name: "down from a collapsed section skips its rows",612 name: "down from a collapsed section skips its rows",
613 keys: func(m *Model) { m.Left(); m.Left(); m.Down() },613 keys: func(m *Model) { m.Left(); m.Left(); m.Down() },
614 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},614 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
615- wantCursor: "\x00section:devbox",615+ wantCursor: "\x00section:0:devbox",
616 },616 },
617 {617 {
618 name: "right expands it again",618 name: "right expands it again",
619 keys: func(m *Model) { m.Left(); m.Left(); m.Right() },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"},620 wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
621- wantCursor: "\x00section:LOCAL",621+ wantCursor: "\x00section:0:LOCAL",
622 },622 },
623 {623 {
624 name: "toggle collapses and expands",624 name: "toggle collapses and expands",
625 keys: func(m *Model) { m.Left(); m.ToggleSection() },625 keys: func(m *Model) { m.Left(); m.ToggleSection() },
626 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},626 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
627- wantCursor: "\x00section:LOCAL",627+ wantCursor: "\x00section:0:LOCAL",
628 },628 },
629 {629 {
630 name: "right off a section does nothing",630 name: "right off a section does nothing",
@@ -652,7 +652,7 @@ func TestSectionFoldSurvivesRepublication(t *testing.T) {
652 m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1})652 m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1})
653 require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))653 require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
654 key, _ := m.cursorKey()654 key, _ := m.cursorKey()
655- require.Equal(t, "\x00section:LOCAL", key)655+ require.Equal(t, "\x00section:0:LOCAL", key)
656 _, ok := m.Selected()656 _, ok := m.Selected()
657 require.False(t, ok, "a section header is never committable")657 require.False(t, ok, "a section header is never committable")
658 658
@@ -703,5 +703,76 @@ func TestExitSearchRestoresFolds(t *testing.T) {
703 m.ExitSearch()703 m.ExitSearch()
704 require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))704 require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
705 key, _ := m.cursorKey()705 key, _ := m.cursorKey()
706- require.Equal(t, "\x00section:LOCAL", key, "a match a fold hides leaves the cursor on its section")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+ }
707 }778 }
M internal/usecase/picker/search.go +8 -12
@@ -54,7 +54,7 @@ func (m *Model) ExitSearch() {
5454 m.searchMatches = nil
5555 m.matchRows = nil
5656 m.refreshView()
57- m.normalizeCursor(-1)
57+ m.normalizeCursor()
5858 }
5959
6060 func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
@@ -130,7 +130,7 @@ func (m *Model) ClearSearch() {
130130 return
131131 }
132132 m.query.SetValue("")
133- m.refreshSearch(false)
133+ m.refreshSearch(true)
134134 }
135135
136136 func (m *Model) MatchCount() int {
@@ -181,7 +181,7 @@ func (m *Model) refreshSearch(selectFirst bool) {
181181 }
182182 m.refreshView()
183183 if selectFirst {
184- m.normalizeCursor(-1)
184+ m.normalizeCursor()
185185 }
186186 return
187187 }
@@ -207,18 +207,14 @@ func (m *Model) refreshSearch(selectFirst bool) {
207207 }
208208 }
209209
210-// normalizeCursor places the cursor on a row the active query still shows. It
211-// keeps the current row when it qualifies, otherwise it takes the best match,
212-// then the nearest eligible row, and finally clears the cursor when nothing
213-// qualifies.
214-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() {
215215 if m == nil || m.eligible(m.selected) {
216216 return
217217 }
218- if best >= 0 {
219- m.selected = best
220- return
221- }
222218 if m.searchRestricted() {
223219 // The query shows nothing the cursor may rest on.
224220 m.selected = -1
@@ -54,7 +54,7 @@ func (m *Model) ExitSearch() {
54 m.searchMatches = nil54 m.searchMatches = nil
55 m.matchRows = nil55 m.matchRows = nil
56 m.refreshView()56 m.refreshView()
57- m.normalizeCursor(-1)57+ m.normalizeCursor()
58 }58 }
59 59
60 func (m *Model) SearchActive() bool { return m != nil && m.searchActive }60 func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
@@ -130,7 +130,7 @@ func (m *Model) ClearSearch() {
130 return130 return
131 }131 }
132 m.query.SetValue("")132 m.query.SetValue("")
133- m.refreshSearch(false)133+ m.refreshSearch(true)
134 }134 }
135 135
136 func (m *Model) MatchCount() int {136 func (m *Model) MatchCount() int {
@@ -181,7 +181,7 @@ func (m *Model) refreshSearch(selectFirst bool) {
181 }181 }
182 m.refreshView()182 m.refreshView()
183 if selectFirst {183 if selectFirst {
184- m.normalizeCursor(-1)184+ m.normalizeCursor()
185 }185 }
186 return186 return
187 }187 }
@@ -207,18 +207,14 @@ func (m *Model) refreshSearch(selectFirst bool) {
207 }207 }
208 }208 }
209 209
210-// 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
211-// 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
212-// then the nearest eligible row, and finally clears the cursor when nothing212+// cursor to the collapsed section, and anything else takes the nearest
213-// qualifies.213+// eligible row. It clears the cursor when nothing qualifies.
214-func (m *Model) normalizeCursor(best int) {214+func (m *Model) normalizeCursor() {
215 if m == nil || m.eligible(m.selected) {215 if m == nil || m.eligible(m.selected) {
216 return216 return
217 }217 }
218- if best >= 0 {
219- m.selected = best
220- return
221- }
222 if m.searchRestricted() {218 if m.searchRestricted() {
223 // The query shows nothing the cursor may rest on.219 // The query shows nothing the cursor may rest on.
224 m.selected = -1220 m.selected = -1

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