detached 417e024

fix(picker): keep the cursor on the previous row when the selected row leaves

commit
417e0242e4556db3b08da8f5deb65d7d8dcbe31e
author
brice · · 2026-09-23 19:59 UTC
parent
ba2c6f3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +56 -0
M internal/usecase/picker/model.go +32 -0
@@ -204,11 +204,43 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
204204 return
205205 }
206206 key, hadKey := m.cursorKey()
207+ above := m.keysAboveCursor()
207208 m.lines = append(m.lines[:0], lines...)
208209 if !hadKey {
209210 key, hadKey = cursor.Key, cursor.Key != ""
210211 }
211212 m.rebuild(key, hadKey, cursor.Index)
213+ if hadKey {
214+ if current, ok := m.cursorKey(); !ok || current != key {
215+ // The selected row left the publication (for example it was
216+ // killed): rest on the nearest surviving row above it instead of
217+ // jumping back to the top.
218+ m.selectFirstKey(above)
219+ }
220+ }
221+}
222+
223+// keysAboveCursor lists the eligible row keys above the cursor, nearest first.
224+func (m *Model) keysAboveCursor() []string {
225+ var keys []string
226+ for i := m.selected - 1; i >= 0; i-- {
227+ if m.eligible(i) {
228+ keys = append(keys, m.rows[i].key())
229+ }
230+ }
231+ return keys
232+}
233+
234+// selectFirstKey moves the cursor to the first key still present, if any.
235+func (m *Model) selectFirstKey(keys []string) {
236+ for _, key := range keys {
237+ for idx, candidate := range m.rows {
238+ if m.eligible(idx) && candidate.key() == key {
239+ m.selected = idx
240+ return
241+ }
242+ }
243+ }
212244 }
213245
214246 // ReplaceProjection atomically changes the daemon-authored projection and its
@@ -204,11 +204,43 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker
204 return204 return
205 }205 }
206 key, hadKey := m.cursorKey()206 key, hadKey := m.cursorKey()
207+ above := m.keysAboveCursor()
207 m.lines = append(m.lines[:0], lines...)208 m.lines = append(m.lines[:0], lines...)
208 if !hadKey {209 if !hadKey {
209 key, hadKey = cursor.Key, cursor.Key != ""210 key, hadKey = cursor.Key, cursor.Key != ""
210 }211 }
211 m.rebuild(key, hadKey, cursor.Index)212 m.rebuild(key, hadKey, cursor.Index)
213+ if hadKey {
214+ if current, ok := m.cursorKey(); !ok || current != key {
215+ // The selected row left the publication (for example it was
216+ // killed): rest on the nearest surviving row above it instead of
217+ // jumping back to the top.
218+ m.selectFirstKey(above)
219+ }
220+ }
221+}
222+
223+// keysAboveCursor lists the eligible row keys above the cursor, nearest first.
224+func (m *Model) keysAboveCursor() []string {
225+ var keys []string
226+ for i := m.selected - 1; i >= 0; i-- {
227+ if m.eligible(i) {
228+ keys = append(keys, m.rows[i].key())
229+ }
230+ }
231+ return keys
232+}
233+
234+// selectFirstKey moves the cursor to the first key still present, if any.
235+func (m *Model) selectFirstKey(keys []string) {
236+ for _, key := range keys {
237+ for idx, candidate := range m.rows {
238+ if m.eligible(idx) && candidate.key() == key {
239+ m.selected = idx
240+ return
241+ }
242+ }
243+ }
212 }244 }
213 245
214 // ReplaceProjection atomically changes the daemon-authored projection and its246 // ReplaceProjection atomically changes the daemon-authored projection and its
M internal/usecase/picker/model_test.go +24 -0
@@ -435,3 +435,27 @@ func rowText(row []renderer.Cell) string {
435435 }
436436 return string(out)
437437 }
438+
439+func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) {
440+ all := []protocol.PickerLine{navLine("a", "a"), navLine("b", "b"), navLine("c", "c"), navLine("d", "d")}
441+ tests := []struct {
442+ name string
443+ cursor string
444+ next []protocol.PickerLine
445+ want string
446+ }{
447+ {name: "middle row killed", cursor: "c", next: []protocol.PickerLine{all[0], all[1], all[3]}, want: "b"},
448+ {name: "last row killed", cursor: "d", next: all[:3], want: "c"},
449+ {name: "first row killed falls to the nearest", cursor: "a", next: all[1:], want: "b"},
450+ {name: "unchanged row keeps the cursor", cursor: "c", next: all, want: "c"},
451+ }
452+ for _, tt := range tests {
453+ t.Run(tt.name, func(t *testing.T) {
454+ m := New(all, Config{Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}})
455+ m.ReplaceLines(tt.next, protocol.PickerCursor{Index: -1})
456+ selected, ok := m.Selected()
457+ require.True(t, ok)
458+ require.Equal(t, tt.want, selected.Key)
459+ })
460+ }
461+}
@@ -435,3 +435,27 @@ func rowText(row []renderer.Cell) string {
435 }435 }
436 return string(out)436 return string(out)
437 }437 }
438+
439+func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) {
440+ all := []protocol.PickerLine{navLine("a", "a"), navLine("b", "b"), navLine("c", "c"), navLine("d", "d")}
441+ tests := []struct {
442+ name string
443+ cursor string
444+ next []protocol.PickerLine
445+ want string
446+ }{
447+ {name: "middle row killed", cursor: "c", next: []protocol.PickerLine{all[0], all[1], all[3]}, want: "b"},
448+ {name: "last row killed", cursor: "d", next: all[:3], want: "c"},
449+ {name: "first row killed falls to the nearest", cursor: "a", next: all[1:], want: "b"},
450+ {name: "unchanged row keeps the cursor", cursor: "c", next: all, want: "c"},
451+ }
452+ for _, tt := range tests {
453+ t.Run(tt.name, func(t *testing.T) {
454+ m := New(all, Config{Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}})
455+ m.ReplaceLines(tt.next, protocol.PickerCursor{Index: -1})
456+ selected, ok := m.Selected()
457+ require.True(t, ok)
458+ require.Equal(t, tt.want, selected.Key)
459+ })
460+ }
461+}

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