detached 3bd98ac

fix(picker): rest on the nearest row above when the cursor row stops matching

commit
3bd98ac8fbb8406ab8ed56fc347b3930740e8c09
author
brice · · 2026-10-08 20:07 UTC
parent
ef274b7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +35 -11
M internal/usecase/picker/model.go +20 -11
@@ -253,21 +253,30 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
253253 key, hadKey = cursor.Key, cursor.Key != ""
254254 }
255255 m.rebuild(key, hadKey, cursor.Index)
256- if hadKey && !m.hasRow(key) {
257- if current, ok := m.cursorKey(); !ok || current != key {
258- // The selected row left the publication (for example it was
259- // killed): rest on the nearest surviving row above it instead of
260- // jumping back to the top.
261- m.selectFirstKey(above)
262- }
256+ if hadKey && !m.cursorRestsOn(key) {
257+ // The selected row left the publication (for example it was killed)
258+ // or stopped matching the query: rest on the nearest surviving row
259+ // above it instead of jumping elsewhere.
260+ m.selectFirstKey(above)
263261 }
264262 }
265263
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 {
264+// cursorRestsOn reports whether the cursor is on the row with this id, or on
265+// the collapsed section that hides it.
266+func (m *Model) cursorRestsOn(id string) bool {
267+ current, ok := m.cursorKey()
268+ if !ok {
269+ return false
270+ }
271+ if current == id {
272+ return true
273+ }
274+ if !m.rows[m.selected].section() {
275+ return false
276+ }
277+ for idx, r := range m.rows {
269278 if r.id() == id {
270- return true
279+ return m.sectionOf(idx) == m.selected
271280 }
272281 }
273282 return false
@@ -253,21 +253,30 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
253 key, hadKey = cursor.Key, cursor.Key != ""253 key, hadKey = cursor.Key, cursor.Key != ""
254 }254 }
255 m.rebuild(key, hadKey, cursor.Index)255 m.rebuild(key, hadKey, cursor.Index)
256- if hadKey && !m.hasRow(key) {256+ if hadKey && !m.cursorRestsOn(key) {
257- if current, ok := m.cursorKey(); !ok || current != key {257+ // The selected row left the publication (for example it was killed)
258- // The selected row left the publication (for example it was258+ // or stopped matching the query: rest on the nearest surviving row
259- // killed): rest on the nearest surviving row above it instead of259+ // above it instead of jumping elsewhere.
260- // jumping back to the top.260+ m.selectFirstKey(above)
261- m.selectFirstKey(above)
262- }
263 }261 }
264 }262 }
265 263
266-// hasRow reports whether a row with this id is still published, drawn or not.264+// cursorRestsOn reports whether the cursor is on the row with this id, or on
267-func (m *Model) hasRow(id string) bool {265+// the collapsed section that hides it.
268- for _, r := range m.rows {266+func (m *Model) cursorRestsOn(id string) bool {
267+ current, ok := m.cursorKey()
268+ if !ok {
269+ return false
270+ }
271+ if current == id {
272+ return true
273+ }
274+ if !m.rows[m.selected].section() {
275+ return false
276+ }
277+ for idx, r := range m.rows {
269 if r.id() == id {278 if r.id() == id {
270- return true279+ return m.sectionOf(idx) == m.selected
271 }280 }
272 }281 }
273 return false282 return false
M internal/usecase/picker/model_test.go +15 -0
@@ -567,6 +567,21 @@ func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) {
567567 require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one")
568568 }
569569
570+func TestReplaceLinesPreviousRowWhenTheCursorRowStopsMatching(t *testing.T) {
571+ m := New([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "alps"), navLine("d", "altitude")}, Config{Cursor: protocol.PickerCursor{Key: "c", Index: -1}})
572+ m.EnterSearch()
573+ for _, r := range "al" {
574+ m.InsertSearch(r)
575+ }
576+ m.Down() // alpha -> alps
577+ require.Equal(t, "c", mustSelectedKey(t, m))
578+
579+ // c is still published but no longer matches; the source hints the last row.
580+ m.ReplaceLines([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "cliff"), navLine("d", "altitude")}, protocol.PickerCursor{Index: 3})
581+
582+ require.Equal(t, "a", mustSelectedKey(t, m), "the nearest matching row above wins over the source hint")
583+}
584+
570585 // drawnRows reports the tree prefix and label of every drawn row.
571586 func drawnRows(m *Model) []string {
572587 got := make([]string, 0, len(m.view))
@@ -567,6 +567,21 @@ func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) {
567 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")
568 }568 }
569 569
570+func TestReplaceLinesPreviousRowWhenTheCursorRowStopsMatching(t *testing.T) {
571+ m := New([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "alps"), navLine("d", "altitude")}, Config{Cursor: protocol.PickerCursor{Key: "c", Index: -1}})
572+ m.EnterSearch()
573+ for _, r := range "al" {
574+ m.InsertSearch(r)
575+ }
576+ m.Down() // alpha -> alps
577+ require.Equal(t, "c", mustSelectedKey(t, m))
578+
579+ // c is still published but no longer matches; the source hints the last row.
580+ m.ReplaceLines([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "cliff"), navLine("d", "altitude")}, protocol.PickerCursor{Index: 3})
581+
582+ require.Equal(t, "a", mustSelectedKey(t, m), "the nearest matching row above wins over the source hint")
583+}
584+
570 // drawnRows reports the tree prefix and label of every drawn row.585 // drawnRows reports the tree prefix and label of every drawn row.
571 func drawnRows(m *Model) []string {586 func drawnRows(m *Model) []string {
572 got := make([]string, 0, len(m.view))587 got := make([]string, 0, len(m.view))

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