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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 184 | 184 | } | |
| 185 | 185 | ||
| 186 | 186 | 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() | |
| 201 | 215 | ||
| 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 | + | } | |
| 213 | 232 | ||
| 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) | |
| 216 | 235 | ||
| 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) | |
| 224 | 243 | ||
| 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) | |
| 227 | 246 | ||
| 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 | + | } | |
| 235 | 256 | } | |
| 236 | 257 | ||
| 237 | 258 | 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 = ac | 190 | + | 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 lists | 194 | + | }{ |
| 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] = eph | 199 | + | 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 back | 218 | + | // 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 | |||
| 252 | 252 | if mode == SelectMoveTabSession { | |
| 253 | 253 | return pickerRow.kind == rowSession | |
| 254 | 254 | } | |
| 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 | |
| 256 | 266 | } | |
| 257 | 267 | ||
| 258 | 268 | 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 == rowSession | 253 | return pickerRow.kind == rowSession | ||
| 254 | } | 254 | } | ||
| 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 | |||
| 256 | } | 266 | } | ||
| 257 | 267 | ||||
| 258 | func int64Value(value *int64) (int64, bool) { | 268 | func int64Value(value *int64) (int64, bool) { | ||