detached 18c1193

fix(picker): keep a stopped session selected across the sort toggle

commit
18c11934bac33e8c8dba96e018cc9170d47ef9da
author
brice · · 2026-07-29 13:48 UTC
parent
9683889
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +75 -44
M internal/usecase/daemon/picker_test.go +64 -43
@@ -184,54 +184,75 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
184184 }
185185
186186 func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
187- d, sess, ac, sends, releases := newManualTabSession(t, 2)
188- defer releaseAll(releases)
189- sess.mu.Lock()
190- sess.client = ac
191- secondTabID := domain.TabStableID(sess.tabs[1].stableID)
192- sess.mu.Unlock()
193- sess.mruAt.Store(1)
194- // A second live session, ephemeral and more recently used: recent mode lists
195- // it first, grouped mode moves it below the named session.
196- eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
197- eph.mruAt.Store(5)
198- d.mu.Lock()
199- d.sessions[eph.id] = eph
200- d.mu.Unlock()
187+ // Rows in recent mode are: ephemeral "1" (header + tab), "work" (header +
188+ // two tabs), then the stopped block. Navigation starts on "work"'s active
189+ // tab, so "j" walks down from there.
190+ cases := []struct {
191+ name string
192+ navigate []byte
193+ wantStopped bool
194+ }{
195+ {name: "live tab selection", navigate: []byte("j")},
196+ {name: "stopped session selection", navigate: []byte("jj"), wantStopped: true},
197+ }
198+ for _, tc := range cases {
199+ t.Run(tc.name, func(t *testing.T) {
200+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
201+ defer releaseAll(releases)
202+ sess.mu.Lock()
203+ sess.client = ac
204+ secondTabID := domain.TabStableID(sess.tabs[1].stableID)
205+ sess.mu.Unlock()
206+ sess.mruAt.Store(1)
207+ // A second live session, ephemeral and more recently used: recent mode
208+ // lists it first, grouped mode moves it below the named session.
209+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
210+ eph.mruAt.Store(5)
211+ d.mu.Lock()
212+ d.sessions[eph.id] = eph
213+ d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
214+ d.mu.Unlock()
201215
202- d.enterPicker(sess, ac)
203- awaitFrame(t, sends, ports.MsgOutput)
204- // Move off the attached session's active tab: a navigate rebuild snaps back
205- // to it unless the toggle preserves the selection.
206- d.handleInput(sess, ac, []byte("j"))
207- awaitFrame(t, sends, ports.MsgOutput)
208- ac.overlays.pickerMu.Lock()
209- before, ok := ac.overlays.picker.Selected()
210- ac.overlays.pickerMu.Unlock()
211- require.True(t, ok)
212- require.Equal(t, secondTabID, before.TabID)
216+ d.enterPicker(sess, ac)
217+ awaitFrame(t, sends, ports.MsgOutput)
218+ // Move off the attached session's active tab: a navigate rebuild snaps
219+ // back to it unless the toggle preserves the selection.
220+ d.handleInput(sess, ac, tc.navigate)
221+ awaitFrame(t, sends, ports.MsgOutput)
222+ ac.overlays.pickerMu.Lock()
223+ before, ok := ac.overlays.picker.Selected()
224+ ac.overlays.pickerMu.Unlock()
225+ require.True(t, ok)
226+ require.Equal(t, tc.wantStopped, before.Stopped)
227+ if tc.wantStopped {
228+ require.Equal(t, "halted", before.Name)
229+ } else {
230+ require.Equal(t, secondTabID, before.TabID)
231+ }
213232
214- d.handleInput(sess, ac, []byte("s"))
215- awaitFrame(t, sends, ports.MsgOutput)
233+ d.handleInput(sess, ac, []byte("s"))
234+ awaitFrame(t, sends, ports.MsgOutput)
216235
217- require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
218- require.True(t, ac.overlays.pickerActive())
219- ac.overlays.pickerMu.Lock()
220- selected, ok := ac.overlays.picker.Selected()
221- ac.overlays.pickerMu.Unlock()
222- require.True(t, ok)
223- require.Equal(t, before, selected)
236+ require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
237+ require.True(t, ac.overlays.pickerActive())
238+ ac.overlays.pickerMu.Lock()
239+ selected, ok := ac.overlays.picker.Selected()
240+ ac.overlays.pickerMu.Unlock()
241+ require.True(t, ok)
242+ require.Equal(t, before, selected)
224243
225- d.handleInput(sess, ac, []byte("s"))
226- awaitFrame(t, sends, ports.MsgOutput)
244+ d.handleInput(sess, ac, []byte("s"))
245+ awaitFrame(t, sends, ports.MsgOutput)
227246
228- require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
229- require.True(t, ac.overlays.pickerActive())
230- ac.overlays.pickerMu.Lock()
231- selected, ok = ac.overlays.picker.Selected()
232- ac.overlays.pickerMu.Unlock()
233- require.True(t, ok)
234- require.Equal(t, before, selected)
247+ require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
248+ require.True(t, ac.overlays.pickerActive())
249+ ac.overlays.pickerMu.Lock()
250+ selected, ok = ac.overlays.picker.Selected()
251+ ac.overlays.pickerMu.Unlock()
252+ require.True(t, ok)
253+ require.Equal(t, before, selected)
254+ })
255+ }
235256 }
236257
237258 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
@@ -184,54 +184,75 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
184 }184 }
185 185
186 func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {186 func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
187- d, sess, ac, sends, releases := newManualTabSession(t, 2)187+ // Rows in recent mode are: ephemeral "1" (header + tab), "work" (header +
188- defer releaseAll(releases)188+ // two tabs), then the stopped block. Navigation starts on "work"'s active
189- sess.mu.Lock()189+ // tab, so "j" walks down from there.
190- sess.client = ac190+ cases := []struct {
191- secondTabID := domain.TabStableID(sess.tabs[1].stableID)191+ name string
192- sess.mu.Unlock()192+ navigate []byte
193- sess.mruAt.Store(1)193+ wantStopped bool
194- // A second live session, ephemeral and more recently used: recent mode lists194+ }{
195- // it first, grouped mode moves it below the named session.195+ {name: "live tab selection", navigate: []byte("j")},
196- eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}196+ {name: "stopped session selection", navigate: []byte("jj"), wantStopped: true},
197- eph.mruAt.Store(5)197+ }
198- d.mu.Lock()198+ for _, tc := range cases {
199- d.sessions[eph.id] = eph199+ t.Run(tc.name, func(t *testing.T) {
200- d.mu.Unlock()200+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
201+ defer releaseAll(releases)
202+ sess.mu.Lock()
203+ sess.client = ac
204+ secondTabID := domain.TabStableID(sess.tabs[1].stableID)
205+ sess.mu.Unlock()
206+ sess.mruAt.Store(1)
207+ // A second live session, ephemeral and more recently used: recent mode
208+ // lists it first, grouped mode moves it below the named session.
209+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
210+ eph.mruAt.Store(5)
211+ d.mu.Lock()
212+ d.sessions[eph.id] = eph
213+ d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
214+ d.mu.Unlock()
201 215
202- d.enterPicker(sess, ac)216+ d.enterPicker(sess, ac)
203- awaitFrame(t, sends, ports.MsgOutput)217+ awaitFrame(t, sends, ports.MsgOutput)
204- // Move off the attached session's active tab: a navigate rebuild snaps back218+ // Move off the attached session's active tab: a navigate rebuild snaps
205- // to it unless the toggle preserves the selection.219+ // back to it unless the toggle preserves the selection.
206- d.handleInput(sess, ac, []byte("j"))220+ d.handleInput(sess, ac, tc.navigate)
207- awaitFrame(t, sends, ports.MsgOutput)221+ awaitFrame(t, sends, ports.MsgOutput)
208- ac.overlays.pickerMu.Lock()222+ ac.overlays.pickerMu.Lock()
209- before, ok := ac.overlays.picker.Selected()223+ before, ok := ac.overlays.picker.Selected()
210- ac.overlays.pickerMu.Unlock()224+ ac.overlays.pickerMu.Unlock()
211- require.True(t, ok)225+ require.True(t, ok)
212- require.Equal(t, secondTabID, before.TabID)226+ require.Equal(t, tc.wantStopped, before.Stopped)
227+ if tc.wantStopped {
228+ require.Equal(t, "halted", before.Name)
229+ } else {
230+ require.Equal(t, secondTabID, before.TabID)
231+ }
213 232
214- d.handleInput(sess, ac, []byte("s"))233+ d.handleInput(sess, ac, []byte("s"))
215- awaitFrame(t, sends, ports.MsgOutput)234+ awaitFrame(t, sends, ports.MsgOutput)
216 235
217- require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())236+ require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
218- require.True(t, ac.overlays.pickerActive())237+ require.True(t, ac.overlays.pickerActive())
219- ac.overlays.pickerMu.Lock()238+ ac.overlays.pickerMu.Lock()
220- selected, ok := ac.overlays.picker.Selected()239+ selected, ok := ac.overlays.picker.Selected()
221- ac.overlays.pickerMu.Unlock()240+ ac.overlays.pickerMu.Unlock()
222- require.True(t, ok)241+ require.True(t, ok)
223- require.Equal(t, before, selected)242+ require.Equal(t, before, selected)
224 243
225- d.handleInput(sess, ac, []byte("s"))244+ d.handleInput(sess, ac, []byte("s"))
226- awaitFrame(t, sends, ports.MsgOutput)245+ awaitFrame(t, sends, ports.MsgOutput)
227 246
228- require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())247+ require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
229- require.True(t, ac.overlays.pickerActive())248+ require.True(t, ac.overlays.pickerActive())
230- ac.overlays.pickerMu.Lock()249+ ac.overlays.pickerMu.Lock()
231- selected, ok = ac.overlays.picker.Selected()250+ selected, ok = ac.overlays.picker.Selected()
232- ac.overlays.pickerMu.Unlock()251+ ac.overlays.pickerMu.Unlock()
233- require.True(t, ok)252+ require.True(t, ok)
234- require.Equal(t, before, selected)253+ require.Equal(t, before, selected)
254+ })
255+ }
235 }256 }
236 257
237 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {258 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
M internal/usecase/picker/model.go +11 -1
@@ -252,7 +252,17 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
252252 if mode == SelectMoveTabSession {
253253 return pickerRow.kind == rowSession
254254 }
255- return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID
255+ if pickerRow.kind != rowTab {
256+ return false
257+ }
258+ // A stopped session contributes one synthetic tab row carrying no stable tab
259+ // identity, so it matches on session identity alone. Its session ID is
260+ // namespaced ("stopped:<name>"), and move modes drop stopped sessions
261+ // entirely, so no live row can be reached this way.
262+ if current.TabID == "" {
263+ return pickerRow.stopped
264+ }
265+ return pickerRow.tabID == current.TabID
256266 }
257267
258268 func int64Value(value *int64) (int64, bool) {
@@ -252,7 +252,17 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
252 if mode == SelectMoveTabSession {252 if mode == SelectMoveTabSession {
253 return pickerRow.kind == rowSession253 return pickerRow.kind == rowSession
254 }254 }
255- return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID255+ if pickerRow.kind != rowTab {
256+ return false
257+ }
258+ // A stopped session contributes one synthetic tab row carrying no stable tab
259+ // identity, so it matches on session identity alone. Its session ID is
260+ // namespaced ("stopped:<name>"), and move modes drop stopped sessions
261+ // entirely, so no live row can be reached this way.
262+ if current.TabID == "" {
263+ return pickerRow.stopped
264+ }
265+ return pickerRow.tabID == current.TabID
256 }266 }
257 267
258 func int64Value(value *int64) (int64, bool) {268 func int64Value(value *int64) (int64, bool) {

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