Merge pull request #148 from bnema/feat/picker-ordering
feat(picker): MRU session ordering, grouped sort mode, and post-kill selection
9 files · +517 -58
M internal/usecase/daemon/daemon.go +3 -0
| @@ -79,6 +79,9 @@ type Daemon struct { | |||
| 79 | 79 | // reusing a lifecycle identity. | |
| 80 | 80 | lastAllocatedCreatedAt int64 | |
| 81 | 81 | mruSeq atomic.Uint64 | |
| 82 | + | // pickerSort is the picker ordering mode for this daemon's lifetime | |
| 83 | + | // (pickerSortMode); not persisted across restarts. | |
| 84 | + | pickerSort atomic.Uint32 | |
| 82 | 85 | // closing marks that shutdown has irreversibly begun. It is set under mu, | |
| 83 | 86 | // atomically with the event that makes shutdown inevitable (the registry | |
| 84 | 87 | // emptying in killSession, or shutdownAll starting), and checked by route | |
| @@ -79,6 +79,9 @@ type Daemon struct { | |||||
| 79 | // reusing a lifecycle identity. | 79 | // reusing a lifecycle identity. | ||
| 80 | lastAllocatedCreatedAt int64 | 80 | lastAllocatedCreatedAt int64 | ||
| 81 | mruSeq atomic.Uint64 | 81 | mruSeq atomic.Uint64 | ||
| 82 | + | // pickerSort is the picker ordering mode for this daemon's lifetime | |||
| 83 | + | // (pickerSortMode); not persisted across restarts. | |||
| 84 | + | pickerSort atomic.Uint32 | |||
| 82 | // closing marks that shutdown has irreversibly begun. It is set under mu, | 85 | // closing marks that shutdown has irreversibly begun. It is set under mu, | ||
| 83 | // atomically with the event that makes shutdown inevitable (the registry | 86 | // atomically with the event that makes shutdown inevitable (the registry | ||
| 84 | // emptying in killSession, or shutdownAll starting), and checked by route | 87 | // emptying in killSession, or shutdownAll starting), and checked by route | ||
M internal/usecase/daemon/move_picker.go +17 -2
| @@ -137,7 +137,16 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess | |||
| 137 | 137 | return sess, sess.tabs[target.TabIndex] | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 140 | - | func (d *Daemon) refreshPicker(ac *attachedClient) { | |
| 140 | + | type pickerRefreshOptions struct { | |
| 141 | + | // preserveSelection keeps the currently selected target selected across the | |
| 142 | + | // rebuild even for navigate intent (used by the sort toggle). | |
| 143 | + | preserveSelection bool | |
| 144 | + | // nearestRow, when >= 0, selects the row occupying this index after the | |
| 145 | + | // rebuild (used after deletes). -1 disables it. | |
| 146 | + | nearestRow int | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) { | |
| 141 | 150 | sess := ac.currentSession() | |
| 142 | 151 | if sess == nil { | |
| 143 | 152 | return | |
| @@ -150,7 +159,7 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||
| 150 | 159 | } | |
| 151 | 160 | intent, source := rt.pickerIntent, rt.pickerSource | |
| 152 | 161 | current := picker.SourceFilter{} | |
| 153 | - | if intent != pickerNavigate { | |
| 162 | + | if intent != pickerNavigate || opts.preserveSelection { | |
| 154 | 163 | selected, _ := rt.picker.Selected() | |
| 155 | 164 | current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID} | |
| 156 | 165 | } | |
| @@ -162,10 +171,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||
| 162 | 171 | return | |
| 163 | 172 | } | |
| 164 | 173 | } | |
| 174 | + | // Navigate-only: move intents keep the selection-or-close logic above | |
| 175 | + | // authoritative, so a stale row index can never override it. | |
| 176 | + | if intent == pickerNavigate && opts.nearestRow >= 0 { | |
| 177 | + | model.SelectNearestRow(opts.nearestRow) | |
| 178 | + | } | |
| 165 | 179 | rt.pickerMu.Lock() | |
| 166 | 180 | updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source | |
| 167 | 181 | if updated { | |
| 168 | 182 | rt.picker = model | |
| 183 | + | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |
| 169 | 184 | } | |
| 170 | 185 | rt.pickerMu.Unlock() | |
| 171 | 186 | if updated { | |
| @@ -137,7 +137,16 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess | |||||
| 137 | return sess, sess.tabs[target.TabIndex] | 137 | return sess, sess.tabs[target.TabIndex] | ||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
| 140 | - | func (d *Daemon) refreshPicker(ac *attachedClient) { | 140 | + | type pickerRefreshOptions struct { |
| 141 | + | // preserveSelection keeps the currently selected target selected across the | |||
| 142 | + | // rebuild even for navigate intent (used by the sort toggle). | |||
| 143 | + | preserveSelection bool | |||
| 144 | + | // nearestRow, when >= 0, selects the row occupying this index after the | |||
| 145 | + | // rebuild (used after deletes). -1 disables it. | |||
| 146 | + | nearestRow int | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) { | |||
| 141 | sess := ac.currentSession() | 150 | sess := ac.currentSession() | ||
| 142 | if sess == nil { | 151 | if sess == nil { | ||
| 143 | return | 152 | return | ||
| @@ -150,7 +159,7 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||||
| 150 | } | 159 | } | ||
| 151 | intent, source := rt.pickerIntent, rt.pickerSource | 160 | intent, source := rt.pickerIntent, rt.pickerSource | ||
| 152 | current := picker.SourceFilter{} | 161 | current := picker.SourceFilter{} | ||
| 153 | - | if intent != pickerNavigate { | 162 | + | if intent != pickerNavigate || opts.preserveSelection { |
| 154 | selected, _ := rt.picker.Selected() | 163 | selected, _ := rt.picker.Selected() | ||
| 155 | current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID} | 164 | current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID} | ||
| 156 | } | 165 | } | ||
| @@ -162,10 +171,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||||
| 162 | return | 171 | return | ||
| 163 | } | 172 | } | ||
| 164 | } | 173 | } | ||
| 174 | + | // Navigate-only: move intents keep the selection-or-close logic above | |||
| 175 | + | // authoritative, so a stale row index can never override it. | |||
| 176 | + | if intent == pickerNavigate && opts.nearestRow >= 0 { | |||
| 177 | + | model.SelectNearestRow(opts.nearestRow) | |||
| 178 | + | } | |||
| 165 | rt.pickerMu.Lock() | 179 | rt.pickerMu.Lock() | ||
| 166 | updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source | 180 | updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source | ||
| 167 | if updated { | 181 | if updated { | ||
| 168 | rt.picker = model | 182 | rt.picker = model | ||
| 183 | + | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |||
| 169 | } | 184 | } | ||
| 170 | rt.pickerMu.Unlock() | 185 | rt.pickerMu.Unlock() | ||
| 171 | if updated { | 186 | if updated { | ||
M internal/usecase/daemon/overlay_runtime.go +3 -0
| @@ -18,6 +18,7 @@ type overlayRuntime struct { | |||
| 18 | 18 | ||
| 19 | 19 | pickerMu sync.Mutex | |
| 20 | 20 | picker *picker.Model | |
| 21 | + | pickerTitle string | |
| 21 | 22 | pickerIntent pickerIntent | |
| 22 | 23 | pickerSource moveSourceLocator | |
| 23 | 24 | pickerPreview *tab | |
| @@ -381,6 +382,7 @@ type overlayRenderSnapshot struct { | |||
| 381 | 382 | ||
| 382 | 383 | pickerActive bool | |
| 383 | 384 | pickerModel *picker.Model | |
| 385 | + | pickerTitle string | |
| 384 | 386 | previewTab *tab | |
| 385 | 387 | ||
| 386 | 388 | noticesOverlayActive bool | |
| @@ -451,6 +453,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 451 | 453 | rt.pickerMu.Lock() | |
| 452 | 454 | snap.pickerActive = rt.picker != nil | |
| 453 | 455 | snap.pickerModel = rt.picker.Clone() | |
| 456 | + | snap.pickerTitle = rt.pickerTitle | |
| 454 | 457 | snap.previewTab = rt.pickerPreview | |
| 455 | 458 | rt.pickerMu.Unlock() | |
| 456 | 459 | ||
| @@ -18,6 +18,7 @@ type overlayRuntime struct { | |||||
| 18 | 18 | ||||
| 19 | pickerMu sync.Mutex | 19 | pickerMu sync.Mutex | ||
| 20 | picker *picker.Model | 20 | picker *picker.Model | ||
| 21 | + | pickerTitle string | |||
| 21 | pickerIntent pickerIntent | 22 | pickerIntent pickerIntent | ||
| 22 | pickerSource moveSourceLocator | 23 | pickerSource moveSourceLocator | ||
| 23 | pickerPreview *tab | 24 | pickerPreview *tab | ||
| @@ -381,6 +382,7 @@ type overlayRenderSnapshot struct { | |||||
| 381 | 382 | ||||
| 382 | pickerActive bool | 383 | pickerActive bool | ||
| 383 | pickerModel *picker.Model | 384 | pickerModel *picker.Model | ||
| 385 | + | pickerTitle string | |||
| 384 | previewTab *tab | 386 | previewTab *tab | ||
| 385 | 387 | ||||
| 386 | noticesOverlayActive bool | 388 | noticesOverlayActive bool | ||
| @@ -451,6 +453,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 451 | rt.pickerMu.Lock() | 453 | rt.pickerMu.Lock() | ||
| 452 | snap.pickerActive = rt.picker != nil | 454 | snap.pickerActive = rt.picker != nil | ||
| 453 | snap.pickerModel = rt.picker.Clone() | 455 | snap.pickerModel = rt.picker.Clone() | ||
| 456 | + | snap.pickerTitle = rt.pickerTitle | |||
| 454 | snap.previewTab = rt.pickerPreview | 457 | snap.previewTab = rt.pickerPreview | ||
| 455 | rt.pickerMu.Unlock() | 458 | rt.pickerMu.Unlock() | ||
| 456 | 459 | ||||
M internal/usecase/daemon/picker.go +83 -4
| @@ -16,6 +16,38 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |
| 18 | 18 | ||
| 19 | + | // pickerSortMode orders the picker's live sessions. It lives for the daemon's | |
| 20 | + | // lifetime only and is never persisted. | |
| 21 | + | type pickerSortMode uint32 | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | pickerSortRecent pickerSortMode = iota | |
| 25 | + | pickerSortGrouped | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | func pickerTitle(mode pickerSortMode) string { | |
| 29 | + | if mode == pickerSortGrouped { | |
| 30 | + | return " Sessions · grouped " | |
| 31 | + | } | |
| 32 | + | return " Sessions · recent " | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // togglePickerSort flips the daemon's picker sort mode between the two known | |
| 36 | + | // modes with a CompareAndSwap loop, so a concurrent double-press can't lose | |
| 37 | + | // an update the way a bare Load-then-Store XOR can. | |
| 38 | + | func (d *Daemon) togglePickerSort() { | |
| 39 | + | for { | |
| 40 | + | cur := pickerSortMode(d.pickerSort.Load()) | |
| 41 | + | next := pickerSortRecent | |
| 42 | + | if cur == pickerSortRecent { | |
| 43 | + | next = pickerSortGrouped | |
| 44 | + | } | |
| 45 | + | if d.pickerSort.CompareAndSwap(uint32(cur), uint32(next)) { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 19 | 51 | // enterPicker preserves the existing navigation entry point. Navigation always | |
| 20 | 52 | // publishes its model, including an empty one; only move entry can fail for a | |
| 21 | 53 | // missing destination. | |
| @@ -32,6 +64,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||
| 32 | 64 | rt.pickerPreviewSession = nil | |
| 33 | 65 | rt.pickerPreview = nil | |
| 34 | 66 | rt.picker = model | |
| 67 | + | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |
| 35 | 68 | rt.pickerIntent = intent | |
| 36 | 69 | rt.pickerSource = source | |
| 37 | 70 | rt.pickerPending = nil | |
| @@ -54,8 +87,42 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||
| 54 | 87 | } | |
| 55 | 88 | } | |
| 56 | 89 | d.mu.Unlock() | |
| 57 | - | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | |
| 58 | - | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 90 | + | // Snapshot mruAt, name, and ephemeral once: comparators must not observe | |
| 91 | + | // concurrent touchMRU updates or a renameSession mutation (which mutates | |
| 92 | + | // name/ephemeral under sess.mu) mid-sort. | |
| 93 | + | type pickerSortSnapshot struct { | |
| 94 | + | mruAt uint64 | |
| 95 | + | name string | |
| 96 | + | ephemeral bool | |
| 97 | + | } | |
| 98 | + | snap := make(map[*session]pickerSortSnapshot, len(sessions)) | |
| 99 | + | for _, s := range sessions { | |
| 100 | + | mruAt := s.mruAt.Load() | |
| 101 | + | s.mu.Lock() | |
| 102 | + | name := s.name | |
| 103 | + | ephemeral := s.ephemeral | |
| 104 | + | s.mu.Unlock() | |
| 105 | + | snap[s] = pickerSortSnapshot{mruAt: mruAt, name: name, ephemeral: ephemeral} | |
| 106 | + | } | |
| 107 | + | sort.Slice(sessions, func(i, j int) bool { | |
| 108 | + | if snap[sessions[i]].mruAt != snap[sessions[j]].mruAt { | |
| 109 | + | return snap[sessions[i]].mruAt > snap[sessions[j]].mruAt | |
| 110 | + | } | |
| 111 | + | return snap[sessions[i]].name < snap[sessions[j]].name | |
| 112 | + | }) | |
| 113 | + | sort.Slice(stopped, func(i, j int) bool { | |
| 114 | + | if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq { | |
| 115 | + | return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq | |
| 116 | + | } | |
| 117 | + | return stopped[i].name < stopped[j].name | |
| 118 | + | }) | |
| 119 | + | if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped { | |
| 120 | + | // Stable partition: named sessions first, ephemeral after, each keeping | |
| 121 | + | // its MRU order from the sort above. | |
| 122 | + | sort.SliceStable(sessions, func(i, j int) bool { | |
| 123 | + | return !snap[sessions[i]].ephemeral && snap[sessions[j]].ephemeral | |
| 124 | + | }) | |
| 125 | + | } | |
| 59 | 126 | ||
| 60 | 127 | for _, s := range sessions { | |
| 61 | 128 | d.refreshSessionFocusedTitles(s) | |
| @@ -114,7 +181,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||
| 114 | 181 | ||
| 115 | 182 | func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | |
| 116 | 183 | views, attachedCurrent := d.pickerViews(cur) | |
| 117 | - | if intent == pickerNavigate { | |
| 184 | + | if intent == pickerNavigate && current == (picker.SourceFilter{}) { | |
| 118 | 185 | current = attachedCurrent | |
| 119 | 186 | } | |
| 120 | 187 | return picker.New(views, picker.SelectionConfig{ | |
| @@ -173,6 +240,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 173 | 240 | switch b { | |
| 174 | 241 | case 'x': | |
| 175 | 242 | return listInputResult{action: b, stop: true} | |
| 243 | + | case 's': | |
| 244 | + | return listInputResult{action: b, stop: true} | |
| 176 | 245 | case '\r', '\n': | |
| 177 | 246 | return listInputResult{action: b, exit: true} | |
| 178 | 247 | default: | |
| @@ -183,12 +252,21 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 183 | 252 | var ok bool | |
| 184 | 253 | var intent pickerIntent | |
| 185 | 254 | var source moveSourceLocator | |
| 255 | + | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | |
| 256 | + | prevIdx := -1 | |
| 186 | 257 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | |
| 187 | 258 | target, ok = rt.picker.Selected() | |
| 259 | + | prevIdx = rt.picker.SelectedIndex() | |
| 188 | 260 | } | |
| 189 | 261 | intent, source = rt.pickerIntent, rt.pickerSource | |
| 190 | 262 | rt.pickerMu.Unlock() | |
| 191 | 263 | ||
| 264 | + | if result.action == 's' { | |
| 265 | + | d.togglePickerSort() | |
| 266 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 267 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 268 | + | return | |
| 269 | + | } | |
| 192 | 270 | if result.action == 'x' { | |
| 193 | 271 | var effect *roleEffectTicket | |
| 194 | 272 | if len(effects) != 0 { | |
| @@ -213,7 +291,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 213 | 291 | } | |
| 214 | 292 | defer fresh.End() | |
| 215 | 293 | } | |
| 216 | - | d.refreshPicker(ac) | |
| 294 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx}) | |
| 217 | 295 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 218 | 296 | return | |
| 219 | 297 | } | |
| @@ -405,6 +483,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 405 | 483 | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 406 | 484 | ac.overlays.pickerMu.Lock() | |
| 407 | 485 | ac.overlays.picker = nil | |
| 486 | + | ac.overlays.pickerTitle = "" | |
| 408 | 487 | ac.overlays.pickerIntent = pickerNavigate | |
| 409 | 488 | ac.overlays.pickerSource = moveSourceLocator{} | |
| 410 | 489 | ac.overlays.pickerPending = nil | |
| @@ -16,6 +16,38 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | 17 | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | ||
| 18 | 18 | ||||
| 19 | + | // pickerSortMode orders the picker's live sessions. It lives for the daemon's | |||
| 20 | + | // lifetime only and is never persisted. | |||
| 21 | + | type pickerSortMode uint32 | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | pickerSortRecent pickerSortMode = iota | |||
| 25 | + | pickerSortGrouped | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | func pickerTitle(mode pickerSortMode) string { | |||
| 29 | + | if mode == pickerSortGrouped { | |||
| 30 | + | return " Sessions · grouped " | |||
| 31 | + | } | |||
| 32 | + | return " Sessions · recent " | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // togglePickerSort flips the daemon's picker sort mode between the two known | |||
| 36 | + | // modes with a CompareAndSwap loop, so a concurrent double-press can't lose | |||
| 37 | + | // an update the way a bare Load-then-Store XOR can. | |||
| 38 | + | func (d *Daemon) togglePickerSort() { | |||
| 39 | + | for { | |||
| 40 | + | cur := pickerSortMode(d.pickerSort.Load()) | |||
| 41 | + | next := pickerSortRecent | |||
| 42 | + | if cur == pickerSortRecent { | |||
| 43 | + | next = pickerSortGrouped | |||
| 44 | + | } | |||
| 45 | + | if d.pickerSort.CompareAndSwap(uint32(cur), uint32(next)) { | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 19 | // enterPicker preserves the existing navigation entry point. Navigation always | 51 | // enterPicker preserves the existing navigation entry point. Navigation always | ||
| 20 | // publishes its model, including an empty one; only move entry can fail for a | 52 | // publishes its model, including an empty one; only move entry can fail for a | ||
| 21 | // missing destination. | 53 | // missing destination. | ||
| @@ -32,6 +64,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||||
| 32 | rt.pickerPreviewSession = nil | 64 | rt.pickerPreviewSession = nil | ||
| 33 | rt.pickerPreview = nil | 65 | rt.pickerPreview = nil | ||
| 34 | rt.picker = model | 66 | rt.picker = model | ||
| 67 | + | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |||
| 35 | rt.pickerIntent = intent | 68 | rt.pickerIntent = intent | ||
| 36 | rt.pickerSource = source | 69 | rt.pickerSource = source | ||
| 37 | rt.pickerPending = nil | 70 | rt.pickerPending = nil | ||
| @@ -54,8 +87,42 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||||
| 54 | } | 87 | } | ||
| 55 | } | 88 | } | ||
| 56 | d.mu.Unlock() | 89 | d.mu.Unlock() | ||
| 57 | - | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | 90 | + | // Snapshot mruAt, name, and ephemeral once: comparators must not observe |
| 58 | - | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | 91 | + | // concurrent touchMRU updates or a renameSession mutation (which mutates |
| 92 | + | // name/ephemeral under sess.mu) mid-sort. | |||
| 93 | + | type pickerSortSnapshot struct { | |||
| 94 | + | mruAt uint64 | |||
| 95 | + | name string | |||
| 96 | + | ephemeral bool | |||
| 97 | + | } | |||
| 98 | + | snap := make(map[*session]pickerSortSnapshot, len(sessions)) | |||
| 99 | + | for _, s := range sessions { | |||
| 100 | + | mruAt := s.mruAt.Load() | |||
| 101 | + | s.mu.Lock() | |||
| 102 | + | name := s.name | |||
| 103 | + | ephemeral := s.ephemeral | |||
| 104 | + | s.mu.Unlock() | |||
| 105 | + | snap[s] = pickerSortSnapshot{mruAt: mruAt, name: name, ephemeral: ephemeral} | |||
| 106 | + | } | |||
| 107 | + | sort.Slice(sessions, func(i, j int) bool { | |||
| 108 | + | if snap[sessions[i]].mruAt != snap[sessions[j]].mruAt { | |||
| 109 | + | return snap[sessions[i]].mruAt > snap[sessions[j]].mruAt | |||
| 110 | + | } | |||
| 111 | + | return snap[sessions[i]].name < snap[sessions[j]].name | |||
| 112 | + | }) | |||
| 113 | + | sort.Slice(stopped, func(i, j int) bool { | |||
| 114 | + | if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq { | |||
| 115 | + | return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq | |||
| 116 | + | } | |||
| 117 | + | return stopped[i].name < stopped[j].name | |||
| 118 | + | }) | |||
| 119 | + | if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped { | |||
| 120 | + | // Stable partition: named sessions first, ephemeral after, each keeping | |||
| 121 | + | // its MRU order from the sort above. | |||
| 122 | + | sort.SliceStable(sessions, func(i, j int) bool { | |||
| 123 | + | return !snap[sessions[i]].ephemeral && snap[sessions[j]].ephemeral | |||
| 124 | + | }) | |||
| 125 | + | } | |||
| 59 | 126 | ||||
| 60 | for _, s := range sessions { | 127 | for _, s := range sessions { | ||
| 61 | d.refreshSessionFocusedTitles(s) | 128 | d.refreshSessionFocusedTitles(s) | ||
| @@ -114,7 +181,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF | |||||
| 114 | 181 | ||||
| 115 | func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | 182 | func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | ||
| 116 | views, attachedCurrent := d.pickerViews(cur) | 183 | views, attachedCurrent := d.pickerViews(cur) | ||
| 117 | - | if intent == pickerNavigate { | 184 | + | if intent == pickerNavigate && current == (picker.SourceFilter{}) { |
| 118 | current = attachedCurrent | 185 | current = attachedCurrent | ||
| 119 | } | 186 | } | ||
| 120 | return picker.New(views, picker.SelectionConfig{ | 187 | return picker.New(views, picker.SelectionConfig{ | ||
| @@ -173,6 +240,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 173 | switch b { | 240 | switch b { | ||
| 174 | case 'x': | 241 | case 'x': | ||
| 175 | return listInputResult{action: b, stop: true} | 242 | return listInputResult{action: b, stop: true} | ||
| 243 | + | case 's': | |||
| 244 | + | return listInputResult{action: b, stop: true} | |||
| 176 | case '\r', '\n': | 245 | case '\r', '\n': | ||
| 177 | return listInputResult{action: b, exit: true} | 246 | return listInputResult{action: b, exit: true} | ||
| 178 | default: | 247 | default: | ||
| @@ -183,12 +252,21 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 183 | var ok bool | 252 | var ok bool | ||
| 184 | var intent pickerIntent | 253 | var intent pickerIntent | ||
| 185 | var source moveSourceLocator | 254 | var source moveSourceLocator | ||
| 255 | + | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | |||
| 256 | + | prevIdx := -1 | |||
| 186 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | 257 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | ||
| 187 | target, ok = rt.picker.Selected() | 258 | target, ok = rt.picker.Selected() | ||
| 259 | + | prevIdx = rt.picker.SelectedIndex() | |||
| 188 | } | 260 | } | ||
| 189 | intent, source = rt.pickerIntent, rt.pickerSource | 261 | intent, source = rt.pickerIntent, rt.pickerSource | ||
| 190 | rt.pickerMu.Unlock() | 262 | rt.pickerMu.Unlock() | ||
| 191 | 263 | ||||
| 264 | + | if result.action == 's' { | |||
| 265 | + | d.togglePickerSort() | |||
| 266 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |||
| 267 | + | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 192 | if result.action == 'x' { | 270 | if result.action == 'x' { | ||
| 193 | var effect *roleEffectTicket | 271 | var effect *roleEffectTicket | ||
| 194 | if len(effects) != 0 { | 272 | if len(effects) != 0 { | ||
| @@ -213,7 +291,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 213 | } | 291 | } | ||
| 214 | defer fresh.End() | 292 | defer fresh.End() | ||
| 215 | } | 293 | } | ||
| 216 | - | d.refreshPicker(ac) | 294 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx}) |
| 217 | d.invalidateRender(sess, ac, true, "picker.go") | 295 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 218 | return | 296 | return | ||
| 219 | } | 297 | } | ||
| @@ -405,6 +483,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 405 | func (d *Daemon) closePicker(ac *attachedClient) { | 483 | func (d *Daemon) closePicker(ac *attachedClient) { | ||
| 406 | ac.overlays.pickerMu.Lock() | 484 | ac.overlays.pickerMu.Lock() | ||
| 407 | ac.overlays.picker = nil | 485 | ac.overlays.picker = nil | ||
| 486 | + | ac.overlays.pickerTitle = "" | |||
| 408 | ac.overlays.pickerIntent = pickerNavigate | 487 | ac.overlays.pickerIntent = pickerNavigate | ||
| 409 | ac.overlays.pickerSource = moveSourceLocator{} | 488 | ac.overlays.pickerSource = moveSourceLocator{} | ||
| 410 | ac.overlays.pickerPending = nil | 489 | ac.overlays.pickerPending = nil | ||
M internal/usecase/daemon/picker_test.go +243 -46
| @@ -118,6 +118,163 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | |||
| 118 | 118 | require.Equal(t, "1", ephemeralView.Name) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | + | func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |
| 122 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | + | old := &session{id: "old", name: "alpha", tabs: []*tab{{}}} | |
| 124 | + | old.mruAt.Store(1) | |
| 125 | + | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 126 | + | eph.mruAt.Store(3) | |
| 127 | + | recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}} | |
| 128 | + | recent.mruAt.Store(2) | |
| 129 | + | d.sessions[old.id] = old | |
| 130 | + | d.sessions[eph.id] = eph | |
| 131 | + | d.sessions[recent.id] = recent | |
| 132 | + | d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1} | |
| 133 | + | d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2} | |
| 134 | + | ||
| 135 | + | views, _ := d.pickerViews(recent) | |
| 136 | + | ||
| 137 | + | names := make([]string, 0, len(views)) | |
| 138 | + | for _, v := range views { | |
| 139 | + | names = append(names, v.Name) | |
| 140 | + | } | |
| 141 | + | // Live MRU-desc (ephemeral "1" is most recent), then stopped block MRU-desc. | |
| 142 | + | require.Equal(t, []string{"1", "zeta", "alpha", "halted-new", "halted-old"}, names) | |
| 143 | + | require.True(t, views[3].Stopped) | |
| 144 | + | require.True(t, views[4].Stopped) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | |
| 148 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 149 | + | b := &session{id: "b", name: "bravo", tabs: []*tab{{}}} | |
| 150 | + | a := &session{id: "a", name: "alpha", tabs: []*tab{{}}} | |
| 151 | + | // Equal mruAt (both zero) must yield a stable alphabetical order. | |
| 152 | + | d.sessions[b.id] = b | |
| 153 | + | d.sessions[a.id] = a | |
| 154 | + | ||
| 155 | + | views, _ := d.pickerViews(a) | |
| 156 | + | ||
| 157 | + | require.Equal(t, "alpha", views[0].Name) | |
| 158 | + | require.Equal(t, "bravo", views[1].Name) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | |
| 162 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 163 | + | d.pickerSort.Store(uint32(pickerSortGrouped)) | |
| 164 | + | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 165 | + | eph.mruAt.Store(5) | |
| 166 | + | named := &session{id: "named", name: "work", tabs: []*tab{{}}} | |
| 167 | + | named.mruAt.Store(2) | |
| 168 | + | named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}} | |
| 169 | + | named2.mruAt.Store(3) | |
| 170 | + | d.sessions[eph.id] = eph | |
| 171 | + | d.sessions[named.id] = named | |
| 172 | + | d.sessions[named2.id] = named2 | |
| 173 | + | d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9} | |
| 174 | + | ||
| 175 | + | views, _ := d.pickerViews(named) | |
| 176 | + | ||
| 177 | + | names := make([]string, 0, len(views)) | |
| 178 | + | for _, v := range views { | |
| 179 | + | names = append(names, v.Name) | |
| 180 | + | } | |
| 181 | + | // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the | |
| 182 | + | // ephemeral session has the highest mruAt. | |
| 183 | + | require.Equal(t, []string{"notes", "work", "1", "halted"}, names) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |
| 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() | |
| 215 | + | ||
| 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 | + | } | |
| 232 | + | ||
| 233 | + | d.handleInput(sess, ac, []byte("s")) | |
| 234 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 235 | + | ||
| 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 | + | title := ac.overlays.pickerTitle | |
| 241 | + | ac.overlays.pickerMu.Unlock() | |
| 242 | + | require.True(t, ok) | |
| 243 | + | require.Equal(t, before, selected) | |
| 244 | + | require.Equal(t, " Sessions · grouped ", title) | |
| 245 | + | ||
| 246 | + | d.handleInput(sess, ac, []byte("s")) | |
| 247 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 248 | + | ||
| 249 | + | require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load()) | |
| 250 | + | require.True(t, ac.overlays.pickerActive()) | |
| 251 | + | ac.overlays.pickerMu.Lock() | |
| 252 | + | selected, ok = ac.overlays.picker.Selected() | |
| 253 | + | title = ac.overlays.pickerTitle | |
| 254 | + | ac.overlays.pickerMu.Unlock() | |
| 255 | + | require.True(t, ok) | |
| 256 | + | require.Equal(t, before, selected) | |
| 257 | + | require.Equal(t, " Sessions · recent ", title) | |
| 258 | + | }) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func TestPickerTitleReflectsSortMode(t *testing.T) { | |
| 263 | + | tests := []struct { | |
| 264 | + | name string | |
| 265 | + | mode pickerSortMode | |
| 266 | + | want string | |
| 267 | + | }{ | |
| 268 | + | {name: "recent", mode: pickerSortRecent, want: " Sessions · recent "}, | |
| 269 | + | {name: "grouped", mode: pickerSortGrouped, want: " Sessions · grouped "}, | |
| 270 | + | } | |
| 271 | + | for _, tc := range tests { | |
| 272 | + | t.Run(tc.name, func(t *testing.T) { | |
| 273 | + | require.Equal(t, tc.want, pickerTitle(tc.mode)) | |
| 274 | + | }) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | ||
| 121 | 278 | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |
| 122 | 279 | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | 280 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| @@ -363,7 +520,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||
| 363 | 520 | destination.mu.Lock() | |
| 364 | 521 | destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0] | |
| 365 | 522 | destination.mu.Unlock() | |
| 366 | - | d.refreshPicker(ac) | |
| 523 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1}) | |
| 367 | 524 | ||
| 368 | 525 | ac.overlays.pickerMu.Lock() | |
| 369 | 526 | after, ok := ac.overlays.picker.Selected() | |
| @@ -412,7 +569,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||
| 412 | 569 | require.True(t, ok) | |
| 413 | 570 | require.Equal(t, domain.SessionID("selected"), before.Session) | |
| 414 | 571 | ||
| 415 | - | d.refreshPicker(ac) | |
| 572 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1}) | |
| 416 | 573 | ||
| 417 | 574 | ac.overlays.pickerMu.Lock() | |
| 418 | 575 | after, ok := ac.overlays.picker.Selected() | |
| @@ -1459,51 +1616,90 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t | |||
| 1459 | 1616 | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | |
| 1460 | 1617 | } | |
| 1461 | 1618 | ||
| 1462 | - | func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) { | |
| 1463 | - | d, current, ac, _, currentReleases := newManualTabSession(t, 2) | |
| 1464 | - | defer releaseAll(currentReleases) | |
| 1465 | - | current.id, current.name, current.active = "current", "z-current", 1 | |
| 1466 | - | current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active" | |
| 1467 | - | delete(d.sessions, domain.SessionID("manual")) | |
| 1468 | - | d.sessions[current.id] = current | |
| 1469 | - | ||
| 1470 | - | otherPTY, releaseOther := newBlockingPTY(t) | |
| 1471 | - | defer releaseOther() | |
| 1472 | - | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | |
| 1473 | - | otherTab.stableID = "other-tab" | |
| 1474 | - | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1475 | - | d.sessions[other.id] = other | |
| 1476 | - | ||
| 1477 | - | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1478 | - | defer releaseTarget() | |
| 1479 | - | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |
| 1480 | - | targetTab.stableID = "target-tab" | |
| 1481 | - | target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1482 | - | d.sessions[target.id] = target | |
| 1483 | - | ||
| 1484 | - | d.enterPicker(current, ac) | |
| 1485 | - | d.handlePickerInput(ac, []byte("k")) | |
| 1486 | - | d.handlePickerInput(ac, []byte("k")) | |
| 1487 | - | ac.overlays.pickerMu.Lock() | |
| 1488 | - | selected, ok := ac.overlays.picker.Selected() | |
| 1489 | - | ac.overlays.pickerMu.Unlock() | |
| 1490 | - | require.True(t, ok) | |
| 1491 | - | require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session") | |
| 1492 | - | ||
| 1493 | - | d.handlePickerInput(ac, []byte("x")) | |
| 1619 | + | // TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow pins the post-kill | |
| 1620 | + | // contract: the cursor stays on the row index the killed session occupied, so | |
| 1621 | + | // it lands on whatever session took its place — clamped to the new last | |
| 1622 | + | // selectable row when the bottom session was killed. Session names drive the | |
| 1623 | + | // picker's alphabetical tie-break (every session here shares mruAt 0), which is | |
| 1624 | + | // what puts the victim in the middle or at the end of the list. | |
| 1625 | + | func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) { | |
| 1626 | + | tests := []struct { | |
| 1627 | + | name string | |
| 1628 | + | currentName string | |
| 1629 | + | otherName string | |
| 1630 | + | targetName string | |
| 1631 | + | nav []byte // keys walked from the attached session's active tab onto the victim | |
| 1632 | + | wantSession domain.SessionID | |
| 1633 | + | wantTabID domain.TabStableID | |
| 1634 | + | wantTabIndex int | |
| 1635 | + | }{ | |
| 1636 | + | { | |
| 1637 | + | // Rows: [a-other, other-tab, m-target, target-tab, z-current, current-first, current-active]. | |
| 1638 | + | // Killing target-tab (row 3) promotes z-current's header to row 2, so | |
| 1639 | + | // row 3 becomes current-first — not the attached active tab. | |
| 1640 | + | name: "middle session killed selects the row taking its place", | |
| 1641 | + | currentName: "z-current", otherName: "a-other", targetName: "m-target", | |
| 1642 | + | nav: []byte("kk"), | |
| 1643 | + | wantSession: "current", wantTabID: "current-first", wantTabIndex: 0, | |
| 1644 | + | }, | |
| 1645 | + | { | |
| 1646 | + | // Rows: [a-current, current-first, current-active, m-other, other-tab, z-target, target-tab]. | |
| 1647 | + | // Killing the bottom row (6) clamps to the new last selectable row. | |
| 1648 | + | name: "last session killed selects the new last row", | |
| 1649 | + | currentName: "a-current", otherName: "m-other", targetName: "z-target", | |
| 1650 | + | nav: []byte("jj"), | |
| 1651 | + | wantSession: "other", wantTabID: "other-tab", wantTabIndex: 0, | |
| 1652 | + | }, | |
| 1653 | + | } | |
| 1654 | + | for _, tt := range tests { | |
| 1655 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1656 | + | d, current, ac, _, currentReleases := newManualTabSession(t, 2) | |
| 1657 | + | defer releaseAll(currentReleases) | |
| 1658 | + | current.id, current.name, current.active = "current", tt.currentName, 1 | |
| 1659 | + | current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active" | |
| 1660 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 1661 | + | d.sessions[current.id] = current | |
| 1662 | + | ||
| 1663 | + | otherPTY, releaseOther := newBlockingPTY(t) | |
| 1664 | + | defer releaseOther() | |
| 1665 | + | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | |
| 1666 | + | otherTab.stableID = "other-tab" | |
| 1667 | + | other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1668 | + | d.sessions[other.id] = other | |
| 1669 | + | ||
| 1670 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1671 | + | defer releaseTarget() | |
| 1672 | + | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |
| 1673 | + | targetTab.stableID = "target-tab" | |
| 1674 | + | target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1675 | + | d.sessions[target.id] = target | |
| 1676 | + | ||
| 1677 | + | d.enterPicker(current, ac) | |
| 1678 | + | for _, key := range tt.nav { | |
| 1679 | + | d.handlePickerInput(ac, []byte{key}) | |
| 1680 | + | } | |
| 1681 | + | ac.overlays.pickerMu.Lock() | |
| 1682 | + | selected, ok := ac.overlays.picker.Selected() | |
| 1683 | + | ac.overlays.pickerMu.Unlock() | |
| 1684 | + | require.True(t, ok) | |
| 1685 | + | require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session") | |
| 1494 | 1686 | ||
| 1495 | - | d.mu.Lock() | |
| 1496 | - | _, targetStillLive := d.sessions[target.id] | |
| 1497 | - | d.mu.Unlock() | |
| 1498 | - | require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session") | |
| 1687 | + | d.handlePickerInput(ac, []byte("x")) | |
| 1499 | 1688 | ||
| 1500 | - | ac.overlays.pickerMu.Lock() | |
| 1501 | - | selected, ok = ac.overlays.picker.Selected() | |
| 1502 | - | ac.overlays.pickerMu.Unlock() | |
| 1503 | - | require.True(t, ok) | |
| 1504 | - | require.Equal(t, current.id, selected.Session) | |
| 1505 | - | require.Equal(t, domain.TabStableID("current-active"), selected.TabID) | |
| 1506 | - | require.Equal(t, 1, selected.TabIndex) | |
| 1689 | + | d.mu.Lock() | |
| 1690 | + | _, targetStillLive := d.sessions[target.id] | |
| 1691 | + | d.mu.Unlock() | |
| 1692 | + | require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session") | |
| 1693 | + | ||
| 1694 | + | ac.overlays.pickerMu.Lock() | |
| 1695 | + | selected, ok = ac.overlays.picker.Selected() | |
| 1696 | + | ac.overlays.pickerMu.Unlock() | |
| 1697 | + | require.True(t, ok) | |
| 1698 | + | require.Equal(t, tt.wantSession, selected.Session) | |
| 1699 | + | require.Equal(t, tt.wantTabID, selected.TabID) | |
| 1700 | + | require.Equal(t, tt.wantTabIndex, selected.TabIndex) | |
| 1701 | + | }) | |
| 1702 | + | } | |
| 1507 | 1703 | } | |
| 1508 | 1704 | ||
| 1509 | 1705 | func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) { | |
| @@ -1563,7 +1759,8 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||
| 1563 | 1759 | ||
| 1564 | 1760 | d.enterPicker(from, ac) | |
| 1565 | 1761 | awaitFrame(t, sends, ports.MsgOutput) | |
| 1566 | - | d.handleInput(from, ac, []byte("k")) | |
| 1762 | + | // MRU-desc order is from(current), recent, older; "j" moves onto "recent". | |
| 1763 | + | d.handleInput(from, ac, []byte("j")) | |
| 1567 | 1764 | awaitFrame(t, sends, ports.MsgOutput) | |
| 1568 | 1765 | d.handleInput(from, ac, []byte("x")) | |
| 1569 | 1766 | ||
| @@ -118,6 +118,163 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | |||||
| 118 | require.Equal(t, "1", ephemeralView.Name) | 118 | require.Equal(t, "1", ephemeralView.Name) | ||
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | + | func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |||
| 122 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 123 | + | old := &session{id: "old", name: "alpha", tabs: []*tab{{}}} | |||
| 124 | + | old.mruAt.Store(1) | |||
| 125 | + | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |||
| 126 | + | eph.mruAt.Store(3) | |||
| 127 | + | recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}} | |||
| 128 | + | recent.mruAt.Store(2) | |||
| 129 | + | d.sessions[old.id] = old | |||
| 130 | + | d.sessions[eph.id] = eph | |||
| 131 | + | d.sessions[recent.id] = recent | |||
| 132 | + | d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1} | |||
| 133 | + | d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2} | |||
| 134 | + | ||||
| 135 | + | views, _ := d.pickerViews(recent) | |||
| 136 | + | ||||
| 137 | + | names := make([]string, 0, len(views)) | |||
| 138 | + | for _, v := range views { | |||
| 139 | + | names = append(names, v.Name) | |||
| 140 | + | } | |||
| 141 | + | // Live MRU-desc (ephemeral "1" is most recent), then stopped block MRU-desc. | |||
| 142 | + | require.Equal(t, []string{"1", "zeta", "alpha", "halted-new", "halted-old"}, names) | |||
| 143 | + | require.True(t, views[3].Stopped) | |||
| 144 | + | require.True(t, views[4].Stopped) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | |||
| 148 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 149 | + | b := &session{id: "b", name: "bravo", tabs: []*tab{{}}} | |||
| 150 | + | a := &session{id: "a", name: "alpha", tabs: []*tab{{}}} | |||
| 151 | + | // Equal mruAt (both zero) must yield a stable alphabetical order. | |||
| 152 | + | d.sessions[b.id] = b | |||
| 153 | + | d.sessions[a.id] = a | |||
| 154 | + | ||||
| 155 | + | views, _ := d.pickerViews(a) | |||
| 156 | + | ||||
| 157 | + | require.Equal(t, "alpha", views[0].Name) | |||
| 158 | + | require.Equal(t, "bravo", views[1].Name) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | |||
| 162 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 163 | + | d.pickerSort.Store(uint32(pickerSortGrouped)) | |||
| 164 | + | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |||
| 165 | + | eph.mruAt.Store(5) | |||
| 166 | + | named := &session{id: "named", name: "work", tabs: []*tab{{}}} | |||
| 167 | + | named.mruAt.Store(2) | |||
| 168 | + | named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}} | |||
| 169 | + | named2.mruAt.Store(3) | |||
| 170 | + | d.sessions[eph.id] = eph | |||
| 171 | + | d.sessions[named.id] = named | |||
| 172 | + | d.sessions[named2.id] = named2 | |||
| 173 | + | d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9} | |||
| 174 | + | ||||
| 175 | + | views, _ := d.pickerViews(named) | |||
| 176 | + | ||||
| 177 | + | names := make([]string, 0, len(views)) | |||
| 178 | + | for _, v := range views { | |||
| 179 | + | names = append(names, v.Name) | |||
| 180 | + | } | |||
| 181 | + | // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the | |||
| 182 | + | // ephemeral session has the highest mruAt. | |||
| 183 | + | require.Equal(t, []string{"notes", "work", "1", "halted"}, names) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |||
| 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() | |||
| 215 | + | ||||
| 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 | + | } | |||
| 232 | + | ||||
| 233 | + | d.handleInput(sess, ac, []byte("s")) | |||
| 234 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 235 | + | ||||
| 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 | + | title := ac.overlays.pickerTitle | |||
| 241 | + | ac.overlays.pickerMu.Unlock() | |||
| 242 | + | require.True(t, ok) | |||
| 243 | + | require.Equal(t, before, selected) | |||
| 244 | + | require.Equal(t, " Sessions · grouped ", title) | |||
| 245 | + | ||||
| 246 | + | d.handleInput(sess, ac, []byte("s")) | |||
| 247 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 248 | + | ||||
| 249 | + | require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load()) | |||
| 250 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 251 | + | ac.overlays.pickerMu.Lock() | |||
| 252 | + | selected, ok = ac.overlays.picker.Selected() | |||
| 253 | + | title = ac.overlays.pickerTitle | |||
| 254 | + | ac.overlays.pickerMu.Unlock() | |||
| 255 | + | require.True(t, ok) | |||
| 256 | + | require.Equal(t, before, selected) | |||
| 257 | + | require.Equal(t, " Sessions · recent ", title) | |||
| 258 | + | }) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func TestPickerTitleReflectsSortMode(t *testing.T) { | |||
| 263 | + | tests := []struct { | |||
| 264 | + | name string | |||
| 265 | + | mode pickerSortMode | |||
| 266 | + | want string | |||
| 267 | + | }{ | |||
| 268 | + | {name: "recent", mode: pickerSortRecent, want: " Sessions · recent "}, | |||
| 269 | + | {name: "grouped", mode: pickerSortGrouped, want: " Sessions · grouped "}, | |||
| 270 | + | } | |||
| 271 | + | for _, tc := range tests { | |||
| 272 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 273 | + | require.Equal(t, tc.want, pickerTitle(tc.mode)) | |||
| 274 | + | }) | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | ||||
| 121 | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | 278 | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | ||
| 122 | d := newTestDaemon(t, nil, stubClock{}) | 279 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 123 | ctx, cancel := context.WithCancel(d.serveCtx) | 280 | ctx, cancel := context.WithCancel(d.serveCtx) | ||
| @@ -363,7 +520,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||||
| 363 | destination.mu.Lock() | 520 | destination.mu.Lock() | ||
| 364 | destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0] | 521 | destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0] | ||
| 365 | destination.mu.Unlock() | 522 | destination.mu.Unlock() | ||
| 366 | - | d.refreshPicker(ac) | 523 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1}) |
| 367 | 524 | ||||
| 368 | ac.overlays.pickerMu.Lock() | 525 | ac.overlays.pickerMu.Lock() | ||
| 369 | after, ok := ac.overlays.picker.Selected() | 526 | after, ok := ac.overlays.picker.Selected() | ||
| @@ -412,7 +569,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||||
| 412 | require.True(t, ok) | 569 | require.True(t, ok) | ||
| 413 | require.Equal(t, domain.SessionID("selected"), before.Session) | 570 | require.Equal(t, domain.SessionID("selected"), before.Session) | ||
| 414 | 571 | ||||
| 415 | - | d.refreshPicker(ac) | 572 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1}) |
| 416 | 573 | ||||
| 417 | ac.overlays.pickerMu.Lock() | 574 | ac.overlays.pickerMu.Lock() | ||
| 418 | after, ok := ac.overlays.picker.Selected() | 575 | after, ok := ac.overlays.picker.Selected() | ||
| @@ -1459,51 +1616,90 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t | |||||
| 1459 | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | 1616 | require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session") | ||
| 1460 | } | 1617 | } | ||
| 1461 | 1618 | ||||
| 1462 | - | func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) { | 1619 | + | // TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow pins the post-kill |
| 1463 | - | d, current, ac, _, currentReleases := newManualTabSession(t, 2) | 1620 | + | // contract: the cursor stays on the row index the killed session occupied, so |
| 1464 | - | defer releaseAll(currentReleases) | 1621 | + | // it lands on whatever session took its place — clamped to the new last |
| 1465 | - | current.id, current.name, current.active = "current", "z-current", 1 | 1622 | + | // selectable row when the bottom session was killed. Session names drive the |
| 1466 | - | current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active" | 1623 | + | // picker's alphabetical tie-break (every session here shares mruAt 0), which is |
| 1467 | - | delete(d.sessions, domain.SessionID("manual")) | 1624 | + | // what puts the victim in the middle or at the end of the list. |
| 1468 | - | d.sessions[current.id] = current | 1625 | + | func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) { |
| 1469 | - | 1626 | + | tests := []struct { | |
| 1470 | - | otherPTY, releaseOther := newBlockingPTY(t) | 1627 | + | name string |
| 1471 | - | defer releaseOther() | 1628 | + | currentName string |
| 1472 | - | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | 1629 | + | otherName string |
| 1473 | - | otherTab.stableID = "other-tab" | 1630 | + | targetName string |
| 1474 | - | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | 1631 | + | nav []byte // keys walked from the attached session's active tab onto the victim |
| 1475 | - | d.sessions[other.id] = other | 1632 | + | wantSession domain.SessionID |
| 1476 | - | 1633 | + | wantTabID domain.TabStableID | |
| 1477 | - | targetPTY, releaseTarget := newBlockingPTY(t) | 1634 | + | wantTabIndex int |
| 1478 | - | defer releaseTarget() | 1635 | + | }{ |
| 1479 | - | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | 1636 | + | { |
| 1480 | - | targetTab.stableID = "target-tab" | 1637 | + | // Rows: [a-other, other-tab, m-target, target-tab, z-current, current-first, current-active]. |
| 1481 | - | target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | 1638 | + | // Killing target-tab (row 3) promotes z-current's header to row 2, so |
| 1482 | - | d.sessions[target.id] = target | 1639 | + | // row 3 becomes current-first — not the attached active tab. |
| 1483 | - | 1640 | + | name: "middle session killed selects the row taking its place", | |
| 1484 | - | d.enterPicker(current, ac) | 1641 | + | currentName: "z-current", otherName: "a-other", targetName: "m-target", |
| 1485 | - | d.handlePickerInput(ac, []byte("k")) | 1642 | + | nav: []byte("kk"), |
| 1486 | - | d.handlePickerInput(ac, []byte("k")) | 1643 | + | wantSession: "current", wantTabID: "current-first", wantTabIndex: 0, |
| 1487 | - | ac.overlays.pickerMu.Lock() | 1644 | + | }, |
| 1488 | - | selected, ok := ac.overlays.picker.Selected() | 1645 | + | { |
| 1489 | - | ac.overlays.pickerMu.Unlock() | 1646 | + | // Rows: [a-current, current-first, current-active, m-other, other-tab, z-target, target-tab]. |
| 1490 | - | require.True(t, ok) | 1647 | + | // Killing the bottom row (6) clamps to the new last selectable row. |
| 1491 | - | require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session") | 1648 | + | name: "last session killed selects the new last row", |
| 1492 | - | 1649 | + | currentName: "a-current", otherName: "m-other", targetName: "z-target", | |
| 1493 | - | d.handlePickerInput(ac, []byte("x")) | 1650 | + | nav: []byte("jj"), |
| 1651 | + | wantSession: "other", wantTabID: "other-tab", wantTabIndex: 0, | |||
| 1652 | + | }, | |||
| 1653 | + | } | |||
| 1654 | + | for _, tt := range tests { | |||
| 1655 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1656 | + | d, current, ac, _, currentReleases := newManualTabSession(t, 2) | |||
| 1657 | + | defer releaseAll(currentReleases) | |||
| 1658 | + | current.id, current.name, current.active = "current", tt.currentName, 1 | |||
| 1659 | + | current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active" | |||
| 1660 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 1661 | + | d.sessions[current.id] = current | |||
| 1662 | + | ||||
| 1663 | + | otherPTY, releaseOther := newBlockingPTY(t) | |||
| 1664 | + | defer releaseOther() | |||
| 1665 | + | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | |||
| 1666 | + | otherTab.stableID = "other-tab" | |||
| 1667 | + | other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |||
| 1668 | + | d.sessions[other.id] = other | |||
| 1669 | + | ||||
| 1670 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |||
| 1671 | + | defer releaseTarget() | |||
| 1672 | + | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |||
| 1673 | + | targetTab.stableID = "target-tab" | |||
| 1674 | + | target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |||
| 1675 | + | d.sessions[target.id] = target | |||
| 1676 | + | ||||
| 1677 | + | d.enterPicker(current, ac) | |||
| 1678 | + | for _, key := range tt.nav { | |||
| 1679 | + | d.handlePickerInput(ac, []byte{key}) | |||
| 1680 | + | } | |||
| 1681 | + | ac.overlays.pickerMu.Lock() | |||
| 1682 | + | selected, ok := ac.overlays.picker.Selected() | |||
| 1683 | + | ac.overlays.pickerMu.Unlock() | |||
| 1684 | + | require.True(t, ok) | |||
| 1685 | + | require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session") | |||
| 1494 | 1686 | ||||
| 1495 | - | d.mu.Lock() | 1687 | + | d.handlePickerInput(ac, []byte("x")) |
| 1496 | - | _, targetStillLive := d.sessions[target.id] | |||
| 1497 | - | d.mu.Unlock() | |||
| 1498 | - | require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session") | |||
| 1499 | 1688 | ||||
| 1500 | - | ac.overlays.pickerMu.Lock() | 1689 | + | d.mu.Lock() |
| 1501 | - | selected, ok = ac.overlays.picker.Selected() | 1690 | + | _, targetStillLive := d.sessions[target.id] |
| 1502 | - | ac.overlays.pickerMu.Unlock() | 1691 | + | d.mu.Unlock() |
| 1503 | - | require.True(t, ok) | 1692 | + | require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session") |
| 1504 | - | require.Equal(t, current.id, selected.Session) | 1693 | + | |
| 1505 | - | require.Equal(t, domain.TabStableID("current-active"), selected.TabID) | 1694 | + | ac.overlays.pickerMu.Lock() |
| 1506 | - | require.Equal(t, 1, selected.TabIndex) | 1695 | + | selected, ok = ac.overlays.picker.Selected() |
| 1696 | + | ac.overlays.pickerMu.Unlock() | |||
| 1697 | + | require.True(t, ok) | |||
| 1698 | + | require.Equal(t, tt.wantSession, selected.Session) | |||
| 1699 | + | require.Equal(t, tt.wantTabID, selected.TabID) | |||
| 1700 | + | require.Equal(t, tt.wantTabIndex, selected.TabIndex) | |||
| 1701 | + | }) | |||
| 1702 | + | } | |||
| 1507 | } | 1703 | } | ||
| 1508 | 1704 | ||||
| 1509 | func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) { | 1705 | func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) { | ||
| @@ -1563,7 +1759,8 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||||
| 1563 | 1759 | ||||
| 1564 | d.enterPicker(from, ac) | 1760 | d.enterPicker(from, ac) | ||
| 1565 | awaitFrame(t, sends, ports.MsgOutput) | 1761 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 1566 | - | d.handleInput(from, ac, []byte("k")) | 1762 | + | // MRU-desc order is from(current), recent, older; "j" moves onto "recent". |
| 1763 | + | d.handleInput(from, ac, []byte("j")) | |||
| 1567 | awaitFrame(t, sends, ports.MsgOutput) | 1764 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 1568 | d.handleInput(from, ac, []byte("x")) | 1765 | d.handleInput(from, ac, []byte("x")) | ||
| 1569 | 1766 | ||||
M internal/usecase/daemon/render_pipeline.go +9 -2
| @@ -230,8 +230,15 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 230 | 230 | } | |
| 231 | 231 | if snap.pickerActive && snap.pickerModel != nil { | |
| 232 | 232 | presentation := pickerModal.Resolve(size) | |
| 233 | - | o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true} | |
| 234 | - | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator} | |
| 233 | + | title := snap.pickerTitle | |
| 234 | + | if title == "" { | |
| 235 | + | title = pickerModal.Title | |
| 236 | + | } | |
| 237 | + | o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true} | |
| 238 | + | stoppedStyle := styles.PickerName | |
| 239 | + | stoppedStyle.Attrs |= renderer.AttrDim | |
| 240 | + | stoppedStyle.Italic = true | |
| 241 | + | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle} | |
| 235 | 242 | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) | |
| 236 | 243 | } | |
| 237 | 244 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |
| @@ -230,8 +230,15 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 230 | } | 230 | } | ||
| 231 | if snap.pickerActive && snap.pickerModel != nil { | 231 | if snap.pickerActive && snap.pickerModel != nil { | ||
| 232 | presentation := pickerModal.Resolve(size) | 232 | presentation := pickerModal.Resolve(size) | ||
| 233 | - | o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true} | 233 | + | title := snap.pickerTitle |
| 234 | - | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator} | 234 | + | if title == "" { |
| 235 | + | title = pickerModal.Title | |||
| 236 | + | } | |||
| 237 | + | o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true} | |||
| 238 | + | stoppedStyle := styles.PickerName | |||
| 239 | + | stoppedStyle.Attrs |= renderer.AttrDim | |||
| 240 | + | stoppedStyle.Italic = true | |||
| 241 | + | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle} | |||
| 235 | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) | 242 | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) | ||
| 236 | } | 243 | } | ||
| 237 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | 244 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | ||
M internal/usecase/daemon/render_test.go +2 -2
| @@ -107,7 +107,7 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { | |||
| 107 | 107 | ||
| 108 | 108 | d.enterPicker(sess, ac) | |
| 109 | 109 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 110 | - | pickerTitle := client.Frame.At(36, 2) | |
| 110 | + | pickerTitle := client.Frame.At(31, 2) | |
| 111 | 111 | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | |
| 112 | 112 | undimmedPane := client.Frame.At(0, 1) | |
| 113 | 113 | ||
| @@ -115,7 +115,7 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { | |||
| 115 | 115 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 116 | 116 | dimmedPickerTitle := pickerTitle | |
| 117 | 117 | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style) | |
| 118 | - | require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop") | |
| 118 | + | require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop") | |
| 119 | 119 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | |
| 120 | 120 | } | |
| 121 | 121 | ||
| @@ -107,7 +107,7 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { | |||||
| 107 | 107 | ||||
| 108 | d.enterPicker(sess, ac) | 108 | d.enterPicker(sess, ac) | ||
| 109 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 109 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 110 | - | pickerTitle := client.Frame.At(36, 2) | 110 | + | pickerTitle := client.Frame.At(31, 2) |
| 111 | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | 111 | require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title") | ||
| 112 | undimmedPane := client.Frame.At(0, 1) | 112 | undimmedPane := client.Frame.At(0, 1) | ||
| 113 | 113 | ||||
| @@ -115,7 +115,7 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { | |||||
| 115 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 115 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 116 | dimmedPickerTitle := pickerTitle | 116 | dimmedPickerTitle := pickerTitle | ||
| 117 | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style) | 117 | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style) | ||
| 118 | - | require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop") | 118 | + | require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop") |
| 119 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | 119 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | ||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
M internal/usecase/picker/model.go +52 -2
| @@ -71,6 +71,7 @@ type RenderStyles struct { | |||
| 71 | 71 | // rows, preserving a distinct inactive surface from modal chrome. | |
| 72 | 72 | Background renderer.Style | |
| 73 | 73 | Base renderer.Style // non-selected row fill + suffixes | |
| 74 | + | Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix) | |
| 74 | 75 | Separator renderer.Style // preview separator | |
| 75 | 76 | } | |
| 76 | 77 | ||
| @@ -80,7 +81,10 @@ func defaultRenderStyles() RenderStyles { | |||
| 80 | 81 | base := renderer.DefaultStyle() | |
| 81 | 82 | separator := renderer.DefaultStyle() | |
| 82 | 83 | separator.Attrs = renderer.AttrDim | |
| 83 | - | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator} | |
| 84 | + | stopped := renderer.DefaultStyle() | |
| 85 | + | stopped.Attrs = renderer.AttrDim | |
| 86 | + | stopped.Italic = true | |
| 87 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped} | |
| 84 | 88 | } | |
| 85 | 89 | ||
| 86 | 90 | type Target struct { | |
| @@ -252,7 +256,17 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b | |||
| 252 | 256 | if mode == SelectMoveTabSession { | |
| 253 | 257 | return pickerRow.kind == rowSession | |
| 254 | 258 | } | |
| 255 | - | return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID | |
| 259 | + | if pickerRow.kind != rowTab { | |
| 260 | + | return false | |
| 261 | + | } | |
| 262 | + | // A stopped session contributes one synthetic tab row carrying no stable tab | |
| 263 | + | // identity, so it matches on session identity alone. Its session ID is | |
| 264 | + | // namespaced ("stopped:<name>"), and move modes drop stopped sessions | |
| 265 | + | // entirely, so no live row can be reached this way. | |
| 266 | + | if current.TabID == "" { | |
| 267 | + | return pickerRow.stopped | |
| 268 | + | } | |
| 269 | + | return pickerRow.tabID == current.TabID | |
| 256 | 270 | } | |
| 257 | 271 | ||
| 258 | 272 | func int64Value(value *int64) (int64, bool) { | |
| @@ -313,6 +327,39 @@ func (m *Model) Selected() (Target, bool) { | |||
| 313 | 327 | }, true | |
| 314 | 328 | } | |
| 315 | 329 | ||
| 330 | + | // SelectedIndex reports the raw selected row index. It is -1 only when the | |
| 331 | + | // model is nil or has no rows at all; otherwise it is a real row index even | |
| 332 | + | // when that row is not selectable (see Selected). Row indices are only | |
| 333 | + | // meaningful against this exact model. | |
| 334 | + | func (m *Model) SelectedIndex() int { | |
| 335 | + | if m == nil { | |
| 336 | + | return -1 | |
| 337 | + | } | |
| 338 | + | return m.selected | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // SelectNearestRow selects the first selectable row at or after idx, falling | |
| 342 | + | // back to the last selectable row before it. Callers use it to keep the | |
| 343 | + | // cursor on the row that takes a removed item's place. | |
| 344 | + | func (m *Model) SelectNearestRow(idx int) { | |
| 345 | + | if m == nil || len(m.rows) == 0 { | |
| 346 | + | return | |
| 347 | + | } | |
| 348 | + | idx = clamp(idx, 0, len(m.rows)-1) | |
| 349 | + | for i := idx; i < len(m.rows); i++ { | |
| 350 | + | if m.rows[i].kind.selectable(m.mode) { | |
| 351 | + | m.selected = i | |
| 352 | + | return | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | for i := idx - 1; i >= 0; i-- { | |
| 356 | + | if m.rows[i].kind.selectable(m.mode) { | |
| 357 | + | m.selected = i | |
| 358 | + | return | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 316 | 363 | func (m *Model) Clone() *Model { | |
| 317 | 364 | if m == nil { | |
| 318 | 365 | return nil | |
| @@ -396,6 +443,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 396 | 443 | if idx == m.selected { | |
| 397 | 444 | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted | |
| 398 | 445 | } | |
| 446 | + | if r.stopped && idx != m.selected { | |
| 447 | + | base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped | |
| 448 | + | } | |
| 399 | 449 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |
| 400 | 450 | ||
| 401 | 451 | name := r.dispName | |
| @@ -71,6 +71,7 @@ type RenderStyles struct { | |||||
| 71 | // rows, preserving a distinct inactive surface from modal chrome. | 71 | // rows, preserving a distinct inactive surface from modal chrome. | ||
| 72 | Background renderer.Style | 72 | Background renderer.Style | ||
| 73 | Base renderer.Style // non-selected row fill + suffixes | 73 | Base renderer.Style // non-selected row fill + suffixes | ||
| 74 | + | Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix) | |||
| 74 | Separator renderer.Style // preview separator | 75 | Separator renderer.Style // preview separator | ||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| @@ -80,7 +81,10 @@ func defaultRenderStyles() RenderStyles { | |||||
| 80 | base := renderer.DefaultStyle() | 81 | base := renderer.DefaultStyle() | ||
| 81 | separator := renderer.DefaultStyle() | 82 | separator := renderer.DefaultStyle() | ||
| 82 | separator.Attrs = renderer.AttrDim | 83 | separator.Attrs = renderer.AttrDim | ||
| 83 | - | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator} | 84 | + | stopped := renderer.DefaultStyle() |
| 85 | + | stopped.Attrs = renderer.AttrDim | |||
| 86 | + | stopped.Italic = true | |||
| 87 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped} | |||
| 84 | } | 88 | } | ||
| 85 | 89 | ||||
| 86 | type Target struct { | 90 | type Target struct { | ||
| @@ -252,7 +256,17 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b | |||||
| 252 | if mode == SelectMoveTabSession { | 256 | if mode == SelectMoveTabSession { | ||
| 253 | return pickerRow.kind == rowSession | 257 | return pickerRow.kind == rowSession | ||
| 254 | } | 258 | } | ||
| 255 | - | return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID | 259 | + | if pickerRow.kind != rowTab { |
| 260 | + | return false | |||
| 261 | + | } | |||
| 262 | + | // A stopped session contributes one synthetic tab row carrying no stable tab | |||
| 263 | + | // identity, so it matches on session identity alone. Its session ID is | |||
| 264 | + | // namespaced ("stopped:<name>"), and move modes drop stopped sessions | |||
| 265 | + | // entirely, so no live row can be reached this way. | |||
| 266 | + | if current.TabID == "" { | |||
| 267 | + | return pickerRow.stopped | |||
| 268 | + | } | |||
| 269 | + | return pickerRow.tabID == current.TabID | |||
| 256 | } | 270 | } | ||
| 257 | 271 | ||||
| 258 | func int64Value(value *int64) (int64, bool) { | 272 | func int64Value(value *int64) (int64, bool) { | ||
| @@ -313,6 +327,39 @@ func (m *Model) Selected() (Target, bool) { | |||||
| 313 | }, true | 327 | }, true | ||
| 314 | } | 328 | } | ||
| 315 | 329 | ||||
| 330 | + | // SelectedIndex reports the raw selected row index. It is -1 only when the | |||
| 331 | + | // model is nil or has no rows at all; otherwise it is a real row index even | |||
| 332 | + | // when that row is not selectable (see Selected). Row indices are only | |||
| 333 | + | // meaningful against this exact model. | |||
| 334 | + | func (m *Model) SelectedIndex() int { | |||
| 335 | + | if m == nil { | |||
| 336 | + | return -1 | |||
| 337 | + | } | |||
| 338 | + | return m.selected | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // SelectNearestRow selects the first selectable row at or after idx, falling | |||
| 342 | + | // back to the last selectable row before it. Callers use it to keep the | |||
| 343 | + | // cursor on the row that takes a removed item's place. | |||
| 344 | + | func (m *Model) SelectNearestRow(idx int) { | |||
| 345 | + | if m == nil || len(m.rows) == 0 { | |||
| 346 | + | return | |||
| 347 | + | } | |||
| 348 | + | idx = clamp(idx, 0, len(m.rows)-1) | |||
| 349 | + | for i := idx; i < len(m.rows); i++ { | |||
| 350 | + | if m.rows[i].kind.selectable(m.mode) { | |||
| 351 | + | m.selected = i | |||
| 352 | + | return | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | for i := idx - 1; i >= 0; i-- { | |||
| 356 | + | if m.rows[i].kind.selectable(m.mode) { | |||
| 357 | + | m.selected = i | |||
| 358 | + | return | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | ||||
| 316 | func (m *Model) Clone() *Model { | 363 | func (m *Model) Clone() *Model { | ||
| 317 | if m == nil { | 364 | if m == nil { | ||
| 318 | return nil | 365 | return nil | ||
| @@ -396,6 +443,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 396 | if idx == m.selected { | 443 | if idx == m.selected { | ||
| 397 | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted | 444 | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted | ||
| 398 | } | 445 | } | ||
| 446 | + | if r.stopped && idx != m.selected { | |||
| 447 | + | base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped | |||
| 448 | + | } | |||
| 399 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | 449 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 400 | 450 | ||||
| 401 | name := r.dispName | 451 | name := r.dispName | ||
M internal/usecase/picker/model_test.go +105 -0
| @@ -406,6 +406,90 @@ func TestSelectedMapping(t *testing.T) { | |||
| 406 | 406 | require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got) | |
| 407 | 407 | } | |
| 408 | 408 | ||
| 409 | + | func TestSelectNearestRow(t *testing.T) { | |
| 410 | + | // Three single-tab sessions: rows are [hdrA, tabA, hdrB, tabB, hdrC, tabC]. | |
| 411 | + | sessions := []SessionView{ | |
| 412 | + | {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}}, | |
| 413 | + | {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}}, | |
| 414 | + | {ID: "c", Name: "c", Tabs: []TabEntry{{TabID: "tc", Name: "tc"}}}, | |
| 415 | + | } | |
| 416 | + | tests := []struct { | |
| 417 | + | name string | |
| 418 | + | idx int | |
| 419 | + | cfg SelectionConfig | |
| 420 | + | // wantStart, when set, pins the selection New produced before | |
| 421 | + | // SelectNearestRow runs, so a case cannot silently degrade into one a | |
| 422 | + | // no-op implementation would also pass. | |
| 423 | + | wantStart domain.SessionID | |
| 424 | + | want domain.SessionID | |
| 425 | + | }{ | |
| 426 | + | {name: "exact selectable row", idx: 3, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"}, | |
| 427 | + | {name: "header row snaps to its tab", idx: 2, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"}, | |
| 428 | + | {name: "past end clamps to last selectable", idx: 40, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"}, | |
| 429 | + | { | |
| 430 | + | // Starts selection on "c" (via Current) so a no-op SelectNearestRow | |
| 431 | + | // would leave the selection on "c" and this case would fail; the | |
| 432 | + | // plain SelectNavigationTab config used above starts on "a" already, | |
| 433 | + | // which can't tell a real clamp-to-first from a no-op. | |
| 434 | + | name: "negative clamps to first selectable", | |
| 435 | + | idx: -2, | |
| 436 | + | cfg: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "c", TabID: "tc"}}, | |
| 437 | + | wantStart: "c", | |
| 438 | + | want: "a", | |
| 439 | + | }, | |
| 440 | + | {name: "last row is a tab and stays put", idx: 5, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"}, | |
| 441 | + | } | |
| 442 | + | for _, tc := range tests { | |
| 443 | + | t.Run(tc.name, func(t *testing.T) { | |
| 444 | + | m := New(sessions, tc.cfg) | |
| 445 | + | if tc.wantStart != "" { | |
| 446 | + | pre, ok := m.Selected() | |
| 447 | + | require.True(t, ok) | |
| 448 | + | require.Equal(t, tc.wantStart, pre.Session, "guard: starting selection must not already be the expected result") | |
| 449 | + | } | |
| 450 | + | m.SelectNearestRow(tc.idx) | |
| 451 | + | target, ok := m.Selected() | |
| 452 | + | require.True(t, ok) | |
| 453 | + | require.Equal(t, tc.want, target.Session) | |
| 454 | + | }) | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func TestSelectNearestRowFallsBackBackwardAndToleratesEmptyModels(t *testing.T) { | |
| 459 | + | // Move-tab mode makes only session headers selectable, so the trailing tab | |
| 460 | + | // row has no selectable row at or after it. | |
| 461 | + | m := New([]SessionView{ | |
| 462 | + | {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}}, | |
| 463 | + | {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}}, | |
| 464 | + | }, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{Session: "zzz"}}) | |
| 465 | + | require.Equal(t, 0, m.SelectedIndex(), "guard: the backward fallback below must move the selection off this starting row") | |
| 466 | + | m.SelectNearestRow(3) | |
| 467 | + | target, ok := m.Selected() | |
| 468 | + | require.True(t, ok) | |
| 469 | + | require.Equal(t, domain.SessionID("b"), target.Session) | |
| 470 | + | require.Equal(t, 2, m.SelectedIndex()) | |
| 471 | + | ||
| 472 | + | empty := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | |
| 473 | + | empty.SelectNearestRow(0) | |
| 474 | + | _, ok = empty.Selected() | |
| 475 | + | require.False(t, ok) | |
| 476 | + | require.Equal(t, -1, empty.SelectedIndex()) | |
| 477 | + | ||
| 478 | + | var nilModel *Model | |
| 479 | + | nilModel.SelectNearestRow(3) | |
| 480 | + | require.Equal(t, -1, nilModel.SelectedIndex()) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func TestSelectedIndexReportsRawSelectedRow(t *testing.T) { | |
| 484 | + | m := New([]SessionView{ | |
| 485 | + | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}, {TabID: "two", Name: "two"}}}, | |
| 486 | + | }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "alpha", TabID: "two"}}) | |
| 487 | + | ||
| 488 | + | require.Equal(t, 2, m.SelectedIndex()) | |
| 489 | + | m.Up() | |
| 490 | + | require.Equal(t, 1, m.SelectedIndex()) | |
| 491 | + | } | |
| 492 | + | ||
| 409 | 493 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |
| 410 | 494 | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 411 | 495 | got, ok := m.Selected() | |
| @@ -416,6 +500,27 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||
| 416 | 500 | require.Equal(t, '(', frame.At(5, 0).Rune) | |
| 417 | 501 | } | |
| 418 | 502 | ||
| 503 | + | func TestRenderStopsStoppedRowsDimItalic(t *testing.T) { | |
| 504 | + | live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}} | |
| 505 | + | halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}} | |
| 506 | + | m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 507 | + | ||
| 508 | + | stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim} | |
| 509 | + | styles := defaultRenderStyles() | |
| 510 | + | require.Equal(t, stoppedStyle, styles.Stopped) | |
| 511 | + | ||
| 512 | + | frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | |
| 513 | + | // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (stopped)" header, 3 its tab row. | |
| 514 | + | require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic") | |
| 515 | + | require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic") | |
| 516 | + | require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style") | |
| 517 | + | ||
| 518 | + | selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}}) | |
| 519 | + | selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | |
| 520 | + | require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped") | |
| 521 | + | require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection") | |
| 522 | + | } | |
| 523 | + | ||
| 419 | 524 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| 420 | 525 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 421 | 526 | preview := Preview{ | |
| @@ -406,6 +406,90 @@ func TestSelectedMapping(t *testing.T) { | |||||
| 406 | require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got) | 406 | require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got) | ||
| 407 | } | 407 | } | ||
| 408 | 408 | ||||
| 409 | + | func TestSelectNearestRow(t *testing.T) { | |||
| 410 | + | // Three single-tab sessions: rows are [hdrA, tabA, hdrB, tabB, hdrC, tabC]. | |||
| 411 | + | sessions := []SessionView{ | |||
| 412 | + | {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}}, | |||
| 413 | + | {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}}, | |||
| 414 | + | {ID: "c", Name: "c", Tabs: []TabEntry{{TabID: "tc", Name: "tc"}}}, | |||
| 415 | + | } | |||
| 416 | + | tests := []struct { | |||
| 417 | + | name string | |||
| 418 | + | idx int | |||
| 419 | + | cfg SelectionConfig | |||
| 420 | + | // wantStart, when set, pins the selection New produced before | |||
| 421 | + | // SelectNearestRow runs, so a case cannot silently degrade into one a | |||
| 422 | + | // no-op implementation would also pass. | |||
| 423 | + | wantStart domain.SessionID | |||
| 424 | + | want domain.SessionID | |||
| 425 | + | }{ | |||
| 426 | + | {name: "exact selectable row", idx: 3, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"}, | |||
| 427 | + | {name: "header row snaps to its tab", idx: 2, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"}, | |||
| 428 | + | {name: "past end clamps to last selectable", idx: 40, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"}, | |||
| 429 | + | { | |||
| 430 | + | // Starts selection on "c" (via Current) so a no-op SelectNearestRow | |||
| 431 | + | // would leave the selection on "c" and this case would fail; the | |||
| 432 | + | // plain SelectNavigationTab config used above starts on "a" already, | |||
| 433 | + | // which can't tell a real clamp-to-first from a no-op. | |||
| 434 | + | name: "negative clamps to first selectable", | |||
| 435 | + | idx: -2, | |||
| 436 | + | cfg: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "c", TabID: "tc"}}, | |||
| 437 | + | wantStart: "c", | |||
| 438 | + | want: "a", | |||
| 439 | + | }, | |||
| 440 | + | {name: "last row is a tab and stays put", idx: 5, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"}, | |||
| 441 | + | } | |||
| 442 | + | for _, tc := range tests { | |||
| 443 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 444 | + | m := New(sessions, tc.cfg) | |||
| 445 | + | if tc.wantStart != "" { | |||
| 446 | + | pre, ok := m.Selected() | |||
| 447 | + | require.True(t, ok) | |||
| 448 | + | require.Equal(t, tc.wantStart, pre.Session, "guard: starting selection must not already be the expected result") | |||
| 449 | + | } | |||
| 450 | + | m.SelectNearestRow(tc.idx) | |||
| 451 | + | target, ok := m.Selected() | |||
| 452 | + | require.True(t, ok) | |||
| 453 | + | require.Equal(t, tc.want, target.Session) | |||
| 454 | + | }) | |||
| 455 | + | } | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | func TestSelectNearestRowFallsBackBackwardAndToleratesEmptyModels(t *testing.T) { | |||
| 459 | + | // Move-tab mode makes only session headers selectable, so the trailing tab | |||
| 460 | + | // row has no selectable row at or after it. | |||
| 461 | + | m := New([]SessionView{ | |||
| 462 | + | {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}}, | |||
| 463 | + | {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}}, | |||
| 464 | + | }, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{Session: "zzz"}}) | |||
| 465 | + | require.Equal(t, 0, m.SelectedIndex(), "guard: the backward fallback below must move the selection off this starting row") | |||
| 466 | + | m.SelectNearestRow(3) | |||
| 467 | + | target, ok := m.Selected() | |||
| 468 | + | require.True(t, ok) | |||
| 469 | + | require.Equal(t, domain.SessionID("b"), target.Session) | |||
| 470 | + | require.Equal(t, 2, m.SelectedIndex()) | |||
| 471 | + | ||||
| 472 | + | empty := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 473 | + | empty.SelectNearestRow(0) | |||
| 474 | + | _, ok = empty.Selected() | |||
| 475 | + | require.False(t, ok) | |||
| 476 | + | require.Equal(t, -1, empty.SelectedIndex()) | |||
| 477 | + | ||||
| 478 | + | var nilModel *Model | |||
| 479 | + | nilModel.SelectNearestRow(3) | |||
| 480 | + | require.Equal(t, -1, nilModel.SelectedIndex()) | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | func TestSelectedIndexReportsRawSelectedRow(t *testing.T) { | |||
| 484 | + | m := New([]SessionView{ | |||
| 485 | + | {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}, {TabID: "two", Name: "two"}}}, | |||
| 486 | + | }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "alpha", TabID: "two"}}) | |||
| 487 | + | ||||
| 488 | + | require.Equal(t, 2, m.SelectedIndex()) | |||
| 489 | + | m.Up() | |||
| 490 | + | require.Equal(t, 1, m.SelectedIndex()) | |||
| 491 | + | } | |||
| 492 | + | ||||
| 409 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | 493 | func TestStoppedSessionSelectableAndRendered(t *testing.T) { | ||
| 410 | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) | 494 | m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 411 | got, ok := m.Selected() | 495 | got, ok := m.Selected() | ||
| @@ -416,6 +500,27 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||||
| 416 | require.Equal(t, '(', frame.At(5, 0).Rune) | 500 | require.Equal(t, '(', frame.At(5, 0).Rune) | ||
| 417 | } | 501 | } | ||
| 418 | 502 | ||||
| 503 | + | func TestRenderStopsStoppedRowsDimItalic(t *testing.T) { | |||
| 504 | + | live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}} | |||
| 505 | + | halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}} | |||
| 506 | + | m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 507 | + | ||||
| 508 | + | stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim} | |||
| 509 | + | styles := defaultRenderStyles() | |||
| 510 | + | require.Equal(t, stoppedStyle, styles.Stopped) | |||
| 511 | + | ||||
| 512 | + | frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | |||
| 513 | + | // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (stopped)" header, 3 its tab row. | |||
| 514 | + | require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic") | |||
| 515 | + | require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic") | |||
| 516 | + | require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style") | |||
| 517 | + | ||||
| 518 | + | selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}}) | |||
| 519 | + | selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | |||
| 520 | + | require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped") | |||
| 521 | + | require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection") | |||
| 522 | + | } | |||
| 523 | + | ||||
| 419 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | 524 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | ||
| 420 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | 525 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 421 | preview := Preview{ | 526 | preview := Preview{ | ||