1package daemon
2
3import (
4 "context"
5 "errors"
6 "io"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 renderer "github.com/bnema/vev-vt/ansi"
15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/persist"
17 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/internal/usecase/picker"
21 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
22 themeui "github.com/bnema/vev/internal/usecase/theme"
23)
24
25var (
26 _ domain.TabStableID = moveSourceLocator{}.TabID
27 _ domain.PaneStableID = moveSourceLocator{}.PaneID
28)
29
30// --- test doubles -----------------------------------------------------------
31
32type remotePickerSendErrorTransport struct {
33 err error
34}
35
36func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }
37func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
38func (*remotePickerSendErrorTransport) Close() error { return nil }
39
40type refusingSnapshotDeleteRepository struct {
41 noOpSnapshotRepository
42 err error
43}
44
45func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
46 return nil
47}
48func (s refusingSnapshotDeleteRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
49 return s.err
50}
51
52func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
53 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
54 tb.ctx, tb.cancel = ctx, cancel
55 for _, pane := range tb.panes {
56 pane.ctx, pane.cancel = ctx, cancel
57 }
58 return tb
59}
60
61// stubClock returns timers whose channel never fires, so a scheduler under it
62// blocks in its debounce loop until the session context is cancelled. Used by
63
64func TestAltTForwardsToPTY(t *testing.T) {
65 writes := make(chan []byte, 2)
66 p, releasePTY := newBlockingPTYWithWrites(t, writes)
67 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
68 defer releasePTY()
69
70 d.handleInput(sess, ac, []byte("\x1bt"))
71
72 require.False(t, ac.overlays.pickerActive())
73 require.Equal(t, []byte("\x1bt"), <-writes)
74}
75
76func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
77 d := newTestDaemon(t, nil, stubClock{})
78 ctx, cancel := context.WithCancel(d.serveCtx)
79 defer cancel()
80 current := &session{sessionCore: sessionCore{id: "s1", name: "alpha"}, ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
81 ringing := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
82 ringing.mu.Lock()
83 ringing.tabs[1].attention = true
84 ringing.tabs[1].attentionAt = time.Unix(10, 0)
85 ringing.mu.Unlock()
86 d.sessions[current.id] = current
87 d.sessions[ringing.id] = ringing
88
89 views, currentSelection := d.pickerViews(current, nil)
90
91 require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)
92 require.Len(t, views, 2)
93 require.Equal(t, "alpha", views[0].Name)
94 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
95 require.Equal(t, "beta ", views[1].Name)
96 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
97
98 model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: current.id, TabID: views[0].Tabs[0].TabID}})
99 model.Down()
100 model.Down()
101 target, ok := model.Selected()
102 require.True(t, ok)
103 require.Equal(t, "beta", target.Name)
104 require.NotNil(t, target.ExpectedCreatedAt)
105}
106
107func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
108 d := newTestDaemon(t, nil, stubClock{})
109 current := &session{sessionCore: sessionCore{id: "current", name: "current"}, tabs: []*tab{{}}}
110 ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
111 d.sessions[current.id] = current
112 d.sessions[ephemeral.id] = ephemeral
113
114 views, _ := d.pickerViews(current, nil)
115
116 require.Len(t, views, 2)
117 var ephemeralView picker.SessionView
118 for _, view := range views {
119 if view.ID == ephemeral.id {
120 ephemeralView = view
121 break
122 }
123 }
124 require.Equal(t, ephemeral.id, ephemeralView.ID)
125 require.Equal(t, "1", ephemeralView.Name)
126}
127
128func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
129 d := newTestDaemon(t, nil, stubClock{})
130 old := &session{sessionCore: sessionCore{id: "old", name: "alpha"}, tabs: []*tab{{}}}
131 old.mruAt.Store(1)
132 eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
133 eph.mruAt.Store(3)
134 recent := &session{sessionCore: sessionCore{id: "recent", name: "zeta"}, tabs: []*tab{{}}}
135 recent.mruAt.Store(2)
136 d.sessions[old.id] = old
137 d.sessions[eph.id] = eph
138 d.sessions[recent.id] = recent
139 d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
140 d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
141
142 views, _ := d.pickerViews(recent, nil)
143
144 names := make([]string, 0, len(views))
145 for _, v := range views {
146 names = append(names, v.Name)
147 }
148 // Live MRU-desc (ephemeral "1" is most recent), then stopped block MRU-desc.
149 require.Equal(t, []string{"1", "zeta", "alpha", "halted-new", "halted-old"}, names)
150 require.True(t, views[3].Stopped)
151 require.True(t, views[4].Stopped)
152}
153
154func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
155 d := newTestDaemon(t, nil, stubClock{})
156 b := &session{sessionCore: sessionCore{id: "b", name: "bravo"}, tabs: []*tab{{}}}
157 a := &session{sessionCore: sessionCore{id: "a", name: "alpha"}, tabs: []*tab{{}}}
158 // Equal mruAt (both zero) must yield a stable alphabetical order.
159 d.sessions[b.id] = b
160 d.sessions[a.id] = a
161
162 views, _ := d.pickerViews(a, nil)
163
164 require.Equal(t, "alpha", views[0].Name)
165 require.Equal(t, "bravo", views[1].Name)
166}
167
168func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
169 d := newTestDaemon(t, nil, stubClock{})
170 d.pickerSort.Store(uint32(pickerSortGrouped))
171 eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
172 eph.mruAt.Store(5)
173 named := &session{sessionCore: sessionCore{id: "named", name: "work"}, tabs: []*tab{{}}}
174 named.mruAt.Store(2)
175 named2 := &session{sessionCore: sessionCore{id: "named2", name: "notes"}, tabs: []*tab{{}}}
176 named2.mruAt.Store(3)
177 d.sessions[eph.id] = eph
178 d.sessions[named.id] = named
179 d.sessions[named2.id] = named2
180 d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
181
182 views, _ := d.pickerViews(named, nil)
183
184 names := make([]string, 0, len(views))
185 for _, v := range views {
186 names = append(names, v.Name)
187 }
188 // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the
189 // ephemeral session has the highest mruAt.
190 require.Equal(t, []string{"notes", "work", "1", "halted"}, names)
191}
192
193func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
194 // Rows in recent mode are: ephemeral "1" (header + tab), "work" (header +
195 // two tabs), then the stopped block. Navigation starts on "work"'s active
196 // tab, so "j" walks down from there.
197 cases := []struct {
198 name string
199 navigate []byte
200 wantStopped bool
201 }{
202 {name: "live tab selection", navigate: []byte("j")},
203 {name: "stopped session selection", navigate: []byte("jj"), wantStopped: true},
204 }
205 for _, tc := range cases {
206 t.Run(tc.name, func(t *testing.T) {
207 d, sess, ac, sends, releases := newManualTabSession(t, 2)
208 defer releaseAll(releases)
209 sess.mu.Lock()
210 sess.registerAttachmentLocked(ac)
211 secondTabID := domain.TabStableID(sess.tabs[1].stableID)
212 sess.mu.Unlock()
213 sess.mruAt.Store(1)
214 // A second live session, ephemeral and more recently used: recent mode
215 // lists it first, grouped mode moves it below the named session.
216 eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
217 eph.mruAt.Store(5)
218 d.mu.Lock()
219 d.sessions[eph.id] = eph
220 d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
221 d.mu.Unlock()
222
223 d.enterPicker(sess, ac)
224 awaitFrame(t, sends, ports.MsgOutput)
225 // Move off the attached session's active tab: a navigate rebuild snaps
226 // back to it unless the toggle preserves the selection.
227 d.handleInput(sess, ac, tc.navigate)
228 awaitFrame(t, sends, ports.MsgOutput)
229 ac.overlays.pickerMu.Lock()
230 before, ok := ac.overlays.picker.Selected()
231 ac.overlays.pickerMu.Unlock()
232 require.True(t, ok)
233 require.Equal(t, tc.wantStopped, before.Stopped)
234 if tc.wantStopped {
235 require.Equal(t, "halted", before.Name)
236 } else {
237 require.Equal(t, secondTabID, before.TabID)
238 }
239
240 d.handleInput(sess, ac, []byte("s"))
241 awaitFrame(t, sends, ports.MsgOutput)
242
243 require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
244 require.True(t, ac.overlays.pickerActive())
245 ac.overlays.pickerMu.Lock()
246 selected, ok := ac.overlays.picker.Selected()
247 title := ac.overlays.pickerTitle
248 ac.overlays.pickerMu.Unlock()
249 require.True(t, ok)
250 require.Equal(t, before, selected)
251 require.Equal(t, " Sessions · grouped ", title)
252
253 d.handleInput(sess, ac, []byte("s"))
254 awaitFrame(t, sends, ports.MsgOutput)
255
256 require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
257 require.True(t, ac.overlays.pickerActive())
258 ac.overlays.pickerMu.Lock()
259 selected, ok = ac.overlays.picker.Selected()
260 title = ac.overlays.pickerTitle
261 ac.overlays.pickerMu.Unlock()
262 require.True(t, ok)
263 require.Equal(t, before, selected)
264 require.Equal(t, " Sessions · recent ", title)
265 })
266 }
267}
268
269func TestPickerTitleReflectsSortMode(t *testing.T) {
270 tests := []struct {
271 name string
272 mode pickerSortMode
273 want string
274 }{
275 {name: "recent", mode: pickerSortRecent, want: " Sessions · recent "},
276 {name: "grouped", mode: pickerSortGrouped, want: " Sessions · grouped "},
277 }
278 for _, tc := range tests {
279 t.Run(tc.name, func(t *testing.T) {
280 require.Equal(t, tc.want, pickerTitle(tc.mode))
281 })
282 }
283}
284
285func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
286 d := newTestDaemon(t, nil, stubClock{})
287 ctx, cancel := context.WithCancel(d.serveCtx)
288 defer cancel()
289 active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
290 d.sessions[active.id] = active
291 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
292
293 views, _ := d.pickerViews(active, nil)
294
295 require.Len(t, views, 2)
296 require.NotNil(t, views[0].ExpectedCreatedAt)
297 require.Equal(t, int64(23), *views[0].ExpectedCreatedAt)
298 require.NotNil(t, views[1].ExpectedCreatedAt)
299 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
300}
301
302func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
303 d := newTestDaemon(t, nil, stubClock{})
304 source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}},
305 tabs: []*tab{
306 {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
307 {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
308 },
309 }
310 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}}
311 d.sessions[source.id] = source
312 d.sessions[destination.id] = destination
313 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
314 sourceLocator := moveSourceLocator{
315 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
316 TabID: "source-tab",
317 PaneID: "source-pane",
318 }
319
320 views, current := d.pickerViews(source, nil)
321 require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles")
322 require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current)
323 var sourceView picker.SessionView
324 for _, view := range views {
325 if view.ID == source.id {
326 sourceView = view
327 }
328 }
329 require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs,
330 "daemon capture does not remove the move source")
331
332 paneModel := d.newPickerModel(source, nil, pickerMovePane, sourceLocator, picker.SourceFilter{
333 Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab",
334 })
335 paneTarget, ok := paneModel.Selected()
336 require.True(t, ok)
337 require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID)
338
339 tabModel := d.newPickerModel(source, nil, pickerMoveTab, sourceLocator, picker.SourceFilter{})
340 tabTarget, ok := tabModel.Selected()
341 require.True(t, ok)
342 require.Equal(t, destination.id, tabTarget.Session)
343 require.Equal(t, destination.incarnation, tabTarget.Incarnation)
344 require.Equal(t, destination.name, tabTarget.Name)
345 require.Equal(t, -1, tabTarget.TabIndex)
346}
347
348func TestPickerMoveWithoutDestinationDoesNotPublishOverlay(t *testing.T) {
349 d, sess, ac, _, releases := newManualTabSession(t, 1)
350 defer releases[0]()
351 sess.mu.Lock()
352 sess.incarnation = domain.IncarnationID{1}
353 tb := sess.tabs[0]
354 sess.mu.Unlock()
355 tb.mu.Lock()
356 tabID := tb.stableID
357 paneID := tb.focusedPane().stableID
358 tb.mu.Unlock()
359 source := moveSourceLocator{
360 Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
361 TabID: domain.TabStableID(tabID), PaneID: domain.PaneStableID(paneID),
362 }
363
364 for _, intent := range []pickerIntent{pickerMovePane, pickerMoveTab} {
365 err := d.enterPickerForIntent(sess, ac, intent, source)
366
367 require.ErrorIs(t, err, errNoMoveDestination)
368 require.False(t, ac.overlays.pickerActive())
369 ac.overlays.pickerMu.Lock()
370 require.Nil(t, ac.overlays.pickerPreview)
371 require.Nil(t, ac.overlays.pickerPreviewSession)
372 require.Zero(t, ac.overlays.pickerPreviewGeneration, "failed entry must not publish or retire a picker generation")
373 ac.overlays.pickerMu.Unlock()
374 }
375}
376
377func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) {
378 p1, release1 := newBlockingPTY(t)
379 p2, release2 := newBlockingPTY(t)
380 defer release1()
381 defer release2()
382 d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
383 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "z-source", domain.IncarnationID{1}
384 delete(d.sessions, domain.SessionID("manual"))
385 d.sessions[sourceSession.id] = sourceSession
386 targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel)
387 targetTab.stableID = "destination-tab"
388 targetSession := &session{sessionCore: sessionCore{id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
389 d.sessions[targetSession.id] = targetSession
390 sourceTab := sourceSession.tabs[0]
391 sourceTab.mu.Lock()
392 sourceTab.stableID = "source-tab"
393 sourcePaneID := sourceTab.focusedPane().stableID
394 sourceTab.mu.Unlock()
395 source := moveSourceLocator{
396 Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
397 TabID: "source-tab", PaneID: domain.PaneStableID(sourcePaneID),
398 }
399
400 require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
401 ac.overlays.pickerMu.Lock()
402 storedIntent, storedSource := ac.overlays.pickerIntent, ac.overlays.pickerSource
403 selected, ok := ac.overlays.picker.Selected()
404 generation := ac.overlays.pickerPreviewGeneration
405 require.Same(t, targetTab, ac.overlays.pickerPreview)
406 require.Same(t, targetSession, ac.overlays.pickerPreviewSession)
407 ac.overlays.pickerMu.Unlock()
408 require.Equal(t, pickerMovePane, storedIntent)
409 require.Equal(t, source, storedSource)
410 require.True(t, ok)
411 require.Equal(t, targetSession.id, selected.Session)
412 require.Equal(t, targetSession.incarnation, selected.Incarnation)
413 require.Equal(t, targetSession.name, selected.Name)
414 require.Equal(t, domain.TabStableID(targetTab.stableID), selected.TabID)
415 require.Equal(t, 0, selected.TabIndex)
416 require.Nil(t, selected.ExpectedCreatedAt)
417 rc := targetSession.renderCoordinator()
418 require.NotNil(t, rc)
419 rc.mu.Lock()
420 subscription, subscribed := rc.previewWakes[ac]
421 rc.mu.Unlock()
422 require.True(t, subscribed)
423 require.Equal(t, generation, subscription.generation)
424
425 d.enterPicker(sourceSession, ac)
426
427 rc.mu.Lock()
428 _, subscribed = rc.previewWakes[ac]
429 rc.mu.Unlock()
430 require.False(t, subscribed, "picker replacement must retire the prior cross-session preview subscription")
431 ac.overlays.pickerMu.Lock()
432 require.NotNil(t, ac.overlays.picker)
433 require.Equal(t, pickerNavigate, ac.overlays.pickerIntent)
434 require.Equal(t, moveSourceLocator{}, ac.overlays.pickerSource)
435 ac.overlays.pickerMu.Unlock()
436
437 d.closePicker(ac)
438 ac.overlays.pickerMu.Lock()
439 require.Nil(t, ac.overlays.picker)
440 require.Nil(t, ac.overlays.pickerPreview)
441 ac.overlays.pickerMu.Unlock()
442}
443
444func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing.T) {
445 p1, release1 := newBlockingPTY(t)
446 p2, release2 := newBlockingPTY(t)
447 p3, release3 := newBlockingPTY(t)
448 defer release1()
449 defer release2()
450 defer release3()
451 d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
452 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
453 delete(d.sessions, domain.SessionID("manual"))
454 d.sessions[sourceSession.id] = sourceSession
455 target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true},
456 ctx: sourceSession.ctx, cancel: func() {},
457 tabs: []*tab{
458 newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
459 newTestTabWithContext(p3, sourceSession.ctx, sourceSession.cancel),
460 },
461 }
462 target.tabs[0].stableID, target.tabs[1].stableID = "first", "active"
463 d.sessions[target.id] = target
464 source := moveSourceLocator{
465 Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
466 TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
467 }
468
469 require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
470
471 ac.overlays.pickerMu.Lock()
472 selected, ok := ac.overlays.picker.Selected()
473 require.Same(t, target.tabs[0], ac.overlays.pickerPreview)
474 ac.overlays.pickerMu.Unlock()
475 require.True(t, ok)
476 require.Equal(t, target.id, selected.Session)
477 require.Equal(t, target.incarnation, selected.Incarnation)
478 require.Equal(t, target.name, selected.Name)
479 require.Equal(t, -1, selected.TabIndex)
480 require.Nil(t, selected.ExpectedCreatedAt)
481 require.Equal(t, 0, testAttachmentTabIndex(target), "preview must not mutate destination view")
482 d.closePicker(ac)
483}
484
485func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
486 t.Run("pane destination stable tab", func(t *testing.T) {
487 d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
488 defer releaseAll(sourceReleases)
489 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
490 sourceSession.tabs[0].stableID = "source-tab"
491 delete(d.sessions, domain.SessionID("manual"))
492 d.sessions[sourceSession.id] = sourceSession
493
494 firstPTY, releaseFirst := newBlockingPTY(t)
495 defer releaseFirst()
496 secondPTY, releaseSecond := newBlockingPTY(t)
497 defer releaseSecond()
498 firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel)
499 secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel)
500 firstTab.stableID, secondTab.stableID = "first", "selected"
501 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}}
502 d.sessions[destination.id] = destination
503 source := moveSourceLocator{
504 Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
505 TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
506 }
507
508 require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
509 ac.overlays.pickerMu.Lock()
510 ac.overlays.picker.Down()
511 before, ok := ac.overlays.picker.Selected()
512 ac.overlays.pickerMu.Unlock()
513 require.True(t, ok)
514 require.Equal(t, domain.TabStableID("selected"), before.TabID)
515
516 destination.mu.Lock()
517 destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0]
518 destination.mu.Unlock()
519 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
520
521 ac.overlays.pickerMu.Lock()
522 after, ok := ac.overlays.picker.Selected()
523 ac.overlays.pickerMu.Unlock()
524 require.True(t, ok)
525 require.Equal(t, before.Session, after.Session)
526 require.Equal(t, before.Incarnation, after.Incarnation)
527 require.Equal(t, before.TabID, after.TabID)
528 require.Equal(t, 0, after.TabIndex, "mutable index follows the preserved stable tab")
529 d.closePicker(ac)
530 })
531
532 t.Run("tab destination session", func(t *testing.T) {
533 d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
534 defer releaseAll(sourceReleases)
535 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
536 sourceSession.tabs[0].stableID = "source-tab"
537 delete(d.sessions, domain.SessionID("manual"))
538 d.sessions[sourceSession.id] = sourceSession
539
540 releases := make([]func(), 0, 2)
541 for i, destination := range []struct {
542 id domain.SessionID
543 name string
544 }{{id: "first", name: "0-first"}, {id: "selected", name: "1-selected"}} {
545 pty, release := newBlockingPTY(t)
546 releases = append(releases, release)
547 tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel)
548 tb.stableID = string(destination.id) + "-tab"
549 d.sessions[destination.id] = &session{sessionCore: sessionCore{id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}}
550 }
551 defer releaseAll(releases)
552 source := moveSourceLocator{
553 Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
554 TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
555 }
556
557 require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
558 ac.overlays.pickerMu.Lock()
559 ac.overlays.picker.Down()
560 before, ok := ac.overlays.picker.Selected()
561 ac.overlays.pickerMu.Unlock()
562 require.True(t, ok)
563 require.Equal(t, domain.SessionID("selected"), before.Session)
564
565 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
566
567 ac.overlays.pickerMu.Lock()
568 after, ok := ac.overlays.picker.Selected()
569 ac.overlays.pickerMu.Unlock()
570 require.True(t, ok)
571 require.Equal(t, before, after)
572 d.closePicker(ac)
573 })
574}
575
576func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
577 p, release := newBlockingPTY(t)
578 defer release()
579 d, current, ac, _ := newManualSessionWithPTYs(t, p)
580 ctx, cancel := context.WithCancel(d.serveCtx)
581 defer cancel()
582 original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
583 d.sessions[original.id] = original
584
585 views, _ := d.pickerViews(current, nil)
586 var originalView picker.SessionView
587 for _, view := range views {
588 if view.ID == original.id {
589 originalView = view
590 break
591 }
592 }
593 model := picker.New([]picker.SessionView{originalView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: original.id, TabID: originalView.Tabs[0].TabID}})
594 target, ok := model.Selected()
595 require.True(t, ok)
596
597 delete(d.sessions, original.id)
598 replacement := &session{sessionCore: sessionCore{id: "ephemeral-2", name: original.name, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
599 d.sessions[replacement.id] = replacement
600
601 require.Error(t, d.switchToTarget(current, ac, target))
602 require.Same(t, current, ac.currentSession())
603}
604
605func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
606 p1, releasePTY1 := newBlockingPTY(t)
607 p2, releasePTY2 := newBlockingPTY(t)
608 defer releasePTY1()
609 defer releasePTY2()
610 d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
611 d.shell = "/bin/sh"
612 sess.tabs[1].name = "logs"
613
614 pane0 := sess.tabs[0].focusedPane()
615 pane0.mu.Lock()
616 pane0.title.processName = "vim"
617 pane0.title.processNameValid = true
618 pane0.mu.Unlock()
619
620 sess.mu.Lock()
621 sess.tabs[1].attention = true
622 sess.mu.Unlock()
623
624 views, _ := d.pickerViews(sess, nil)
625
626 require.Len(t, views, 1)
627 require.Equal(t, []picker.TabEntry{
628 {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
629 {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)", Attention: true},
630 }, views[0].Tabs)
631}
632
633func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
634 p1, releasePTY1 := newBlockingPTY(t)
635 p2, releasePTY2 := newBlockingPTY(t)
636 defer releasePTY1()
637 defer releasePTY2()
638 d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
639 d.shell = "/bin/sh"
640 sess.tabs[1].name = "logs"
641
642 pane0 := sess.tabs[0].focusedPane()
643 pane0.mu.Lock()
644 pane0.title.processName = "vim"
645 pane0.title.processNameValid = true
646 pane0.title.terminalTitle = "server.go — vev"
647 pane0.mu.Unlock()
648
649 d.ApplyConfig(domain.Config{Tabs: domain.TabsConfig{TerminalTitle: false}})
650
651 views, _ := d.pickerViews(sess, nil)
652
653 require.Len(t, views, 1)
654 require.Equal(t, []picker.TabEntry{
655 {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
656 {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)"},
657 }, views[0].Tabs)
658}
659
660func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
661 p, release := newBlockingPTY(t)
662 defer release()
663 d, from, ac, _ := newManualSessionWithPTYs(t, p)
664 record := durableRecoveryRecord(0)
665 record.Name = "restoring"
666 state, done := initialSessionState(record)
667 d.stopped[record.Name] = stoppedSession{
668 name: record.Name,
669 createdAt: record.CreatedAt,
670 incarnation: record.IncarnationID,
671 record: record,
672 state: state,
673 restoreDone: done,
674 }
675 factory := &recoveryCountingPTYFactory{}
676 d.ptys = factory
677
678 result := make(chan error, 1)
679 go func() {
680 result <- d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
681 }()
682
683 select {
684 case err := <-result:
685 t.Fatalf("picker switch returned before target restoration completed: %v", err)
686 case <-time.After(25 * time.Millisecond):
687 }
688 require.Zero(t, factory.calls.Load())
689 require.Same(t, from, ac.currentSession())
690
691 ctx, cancel := context.WithCancel(d.serveCtx)
692 defer cancel()
693 target := &session{sessionCore: sessionCore{id: "restored", name: record.Name, createdAt: record.CreatedAt}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}}
694 d.mu.Lock()
695 delete(d.stopped, record.Name)
696 d.sessions[target.id] = target
697 closeRuntimeRestoreDoneLocked(done)
698 d.mu.Unlock()
699
700 require.NoError(t, <-result)
701 require.Same(t, target, ac.currentSession())
702}
703
704func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
705 p, release := newBlockingPTY(t)
706 defer release()
707 d, from, ac, _ := newManualSessionWithPTYs(t, p)
708 record := durableRecoveryRecord(0)
709 record.Name = "restoring"
710 repository := &cancellationRecoveryRepository{started: make(chan struct{})}
711 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
712 coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
713 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
714 WithSnapshotRepository(repository)(d)
715 WithRecoveryCoordinator(coordinator)(d)
716 restoreCtx, cancelRestore := context.WithCancel(context.Background())
717 restored := make(chan struct{})
718 go func() {
719 d.restoreCatalogue(restoreCtx, mustDurableRecords(t, catalogue))
720 close(restored)
721 }()
722 <-repository.started
723
724 result := make(chan error, 1)
725 go func() {
726 result <- d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
727 }()
728 cancelRestore()
729
730 require.Error(t, <-result)
731 <-restored
732 require.Same(t, from, ac.currentSession())
733 d.mu.Lock()
734 entry := d.stopped[record.Name]
735 d.mu.Unlock()
736 require.Equal(t, ports.SessionBroken, entry.state)
737 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
738 select {
739 case <-entry.restoreDone:
740 default:
741 t.Fatal("restore completion was not signaled")
742 }
743}
744
745func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
746 tests := []struct {
747 name string
748 broken bool
749 runtimeState ports.SessionState
750 }{
751 {name: "broken", broken: true, runtimeState: ports.SessionBroken},
752 {name: "healthy without restored runtime", runtimeState: ports.SessionDown},
753 }
754
755 for _, tt := range tests {
756 t.Run(tt.name, func(t *testing.T) {
757 p, release := newBlockingPTY(t)
758 defer release()
759 d, from, ac, _ := newManualSessionWithPTYs(t, p)
760 store, storeState := newMockStore(t)
761 record := durableRecoveryRecord(0)
762 record.Name = "unsafe"
763 if tt.broken {
764 record.DegradedReason = "checkpoint validation failed"
765 }
766 catalogue := persist.New(store)
767 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
768 d.stopped[record.Name] = stoppedSession{
769 name: record.Name,
770 createdAt: record.CreatedAt,
771 incarnation: record.IncarnationID,
772 record: record,
773 state: tt.runtimeState,
774 }
775 factory := &recoveryCountingPTYFactory{}
776 d.ptys = factory
777
778 err := d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
779
780 var userError *domain.UserError
781 require.ErrorAs(t, err, &userError)
782 require.Equal(t, domain.NoticeSessionUnavailable, userError.Code)
783 var protocolError *protoErr
784 require.ErrorAs(t, err, &protocolError)
785 require.Equal(t, ports.ErrInternal, protocolError.code)
786 require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY")
787 require.Same(t, from, ac.currentSession())
788
789 storeState.mu.Lock()
790 sets := storeState.sets
791 storeState.mu.Unlock()
792 require.Zero(t, sets, "unsafe target must not mutate the catalogue")
793 d.mu.Lock()
794 entry := d.stopped[record.Name]
795 d.mu.Unlock()
796 require.Equal(t, record, entry.record)
797 require.Equal(t, tt.runtimeState, entry.state)
798 })
799 }
800}
801
802func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
803 p1, release1 := newBlockingPTY(t)
804 p2, release2 := newBlockingPTY(t)
805 p3, release3 := newBlockingPTY(t)
806 defer release1()
807 defer release2()
808 defer release3()
809 d, from, ac, sends := newManualSessionWithPTYs(t, p1)
810 d.ptys = newFactorySeq(t, p2, p3)
811 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionDown}
812
813 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
814 awaitFrame(t, sends, ports.MsgOutput)
815
816 target := ac.currentSession()
817 require.NotNil(t, target)
818 require.Equal(t, "work", target.name)
819 require.Len(t, target.tabs, 2)
820 require.Equal(t, "shell", target.tabs[0].name)
821 require.Equal(t, "logs", target.tabs[1].name)
822}
823
824func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
825 d, sess, ac, sends, releases := newManualTabSession(t, 2)
826 sess.mu.Lock()
827 sess.registerAttachmentLocked(ac)
828 sess.mu.Unlock()
829 d.ptys = newBlockingOpenFactory(t, d)
830 defer func() {
831 for _, release := range releases {
832 release()
833 }
834 }()
835
836 d.enterPicker(sess, ac)
837 awaitFrame(t, sends, ports.MsgOutput)
838 d.handleInput(sess, ac, []byte("j"))
839 awaitFrame(t, sends, ports.MsgOutput)
840 d.handleInput(sess, ac, []byte("\r"))
841
842 require.Equal(t, 1, testAttachmentTabIndex(sess))
843 requireFloatingInitialized(t, testAttachmentTab(sess))
844 awaitFrame(t, sends, ports.MsgOutput)
845 d.enterPicker(sess, ac)
846 awaitFrame(t, sends, ports.MsgOutput)
847 clk := &signalClock{timers: make(chan *signalTimer, 1)}
848 d.clock = clk
849 d.handleInput(sess, ac, []byte("\x1b"))
850 timer := <-clk.timers
851 require.True(t, ac.overlays.pickerActive())
852 timer.ch <- time.Now()
853 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
854 awaitFrame(t, sends, ports.MsgOutput)
855}
856
857func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
858 cases := []struct {
859 name string
860 input [][]byte
861 wantActive int
862 }{
863 {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
864 {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
865 {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
866 {name: "split SS3 down arrow", input: [][]byte{[]byte("\x1bO"), []byte("B")}, wantActive: 1},
867 }
868 for _, tc := range cases {
869 t.Run(tc.name, func(t *testing.T) {
870 d, sess, ac, sends, releases := newManualTabSession(t, 2)
871 defer func() {
872 for _, release := range releases {
873 release()
874 }
875 }()
876
877 d.enterPicker(sess, ac)
878 awaitFrame(t, sends, ports.MsgOutput)
879 for _, input := range tc.input {
880 d.handleInput(sess, ac, input)
881 }
882 require.True(t, ac.overlays.pickerActive())
883 d.handleInput(sess, ac, []byte("\r"))
884 require.Equal(t, tc.wantActive, testAttachmentTabIndex(sess))
885 })
886 }
887}
888
889func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) {
890 d, sess, ac, _, releases := newManualTabSession(t, 1)
891 defer releases[0]()
892 const sendErr = "navigation back send failed"
893 tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
894 ac.replaceTransport(tr)
895 ac.startupOverlay = ports.StartupOverlaySessionPicker
896 ac.navigationCapabilities = ports.NavigationCapabilityBack
897 ac.overlays.pickerMu.Lock()
898 ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
899 ac.overlays.pickerGeneration++
900 ac.overlays.pickerMu.Unlock()
901
902 rc := d.attachCoordinator(sess, nil, ac, true)
903 token := sess.attachmentToken(ac, tr)
904 token.lease = rc.attachmentLease(ac)
905 ac.publishAttachmentCapability(token)
906 effect, admitted := ac.beginAttachmentEffect(token)
907 require.True(t, admitted)
908 defer effect.End()
909 token.effect = effect
910
911 d.handlePickerInput(ac, []byte("\r"), effect)
912 require.True(t, ac.overlays.pickerActive(), "failed navigation send must leave the picker open")
913 require.Same(t, sess, ac.currentAttachmentSession())
914 history := d.notices.history()
915 require.NotEmpty(t, history)
916 require.Contains(t, history[len(history)-1].Details, sendErr)
917}
918
919func TestPickerNavigationBackWithoutCallerTicketKeepsPickerOpen(t *testing.T) {
920 for _, tc := range []struct {
921 name string
922 passNil bool
923 }{
924 {name: "empty effects"},
925 {name: "nil first effect", passNil: true},
926 } {
927 t.Run(tc.name, func(t *testing.T) {
928 d, sess, ac, _, releases := newManualTabSession(t, 1)
929 defer releases[0]()
930 ac.startupOverlay = ports.StartupOverlaySessionPicker
931 ac.navigationCapabilities = ports.NavigationCapabilityBack
932 ac.overlays.pickerMu.Lock()
933 ac.overlays.picker = picker.New([]picker.SessionView{{ID: sess.id, Name: sess.name, Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{})
934 ac.overlays.pickerGeneration++
935 ac.overlays.pickerMu.Unlock()
936
937 if tc.passNil {
938 d.handlePickerInput(ac, []byte{3}, nil)
939 } else {
940 d.handlePickerInput(ac, []byte{3})
941 }
942
943 require.True(t, ac.overlays.pickerActive(), "back with missing effect must keep picker open when fresh admission is unavailable")
944 })
945 }
946}
947
948func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
949 d, sess, ac, sends, releases := newManualTabSession(t, 1)
950 defer releases[0]()
951
952 d.enterPicker(sess, ac)
953 awaitFrame(t, sends, ports.MsgOutput)
954 clk := &signalClock{timers: make(chan *signalTimer, 1)}
955 d.clock = clk
956 d.handleInput(sess, ac, []byte("\x1b"))
957 timer := <-clk.timers
958 require.True(t, ac.overlays.pickerActive())
959 timer.ch <- time.Now()
960 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
961}
962
963func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
964 p1, releasePTY1 := newBlockingPTY(t)
965 p2, releasePTY2 := newBlockingPTY(t)
966 defer releasePTY1()
967 defer releasePTY2()
968 d := newTestDaemon(t, nil, stubClock{})
969 tr1, sends1 := newCapturingTransport(t)
970 tr2, _ := newCapturingTransport(t)
971 ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
972 ac1.initOverlays()
973 ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
974 ac2.initOverlays()
975 sctx1, cancel1 := context.WithCancel(d.serveCtx)
976 sctx2, cancel2 := context.WithCancel(d.serveCtx)
977 defer cancel1()
978 defer cancel2()
979 sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
980 sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
981 ac1.setSession(sess1)
982 ac2.setSession(sess2)
983 d.sessions[sess1.id] = sess1
984 d.sessions[sess2.id] = sess2
985
986 d.firstPaint(sess1, ac1)
987 awaitFrame(t, sends1, ports.MsgOutput)
988 d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
989 d.firstPaint(sess2, ac1)
990 awaitFrame(t, sends1, ports.MsgOutput)
991 require.Same(t, sess2, ac1.currentSession())
992 for len(sends1) > 0 {
993 <-sends1
994 }
995 oldPane := sess1.tabs[0].focusedPane()
996 oldPane.screen.Write([]byte("stale-damage"))
997 require.NotEmpty(t, oldPane.screen.Damage())
998
999 d.paint(sess1, ac1, false, nil)
1000
1001 require.Zero(t, len(sends1), "stale paint from old session sent a frame")
1002 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
1003}
1004
1005func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing.T) {
1006 p1, releasePTY1 := newBlockingPTY(t)
1007 p2, releasePTY2 := newBlockingPTY(t)
1008 defer releasePTY1()
1009 defer releasePTY2()
1010 d := newTestDaemon(t, nil, stubClock{})
1011 enteredSend := make(chan struct{})
1012 releaseSend := make(chan struct{})
1013 tr := portsmocks.NewMockTransport(t)
1014 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
1015 close(enteredSend)
1016 <-releaseSend
1017 return nil
1018 }).Once()
1019 tr.EXPECT().Close().Return(nil).Maybe()
1020 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1021 ac.initOverlays()
1022 handoffAtSendMu := make(chan struct{})
1023 ac.renderStages = renderStageHooks{
1024 handoffRebase: func() { close(handoffAtSendMu) },
1025 }
1026 sctx1, cancel1 := context.WithCancel(d.serveCtx)
1027 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1028 defer cancel1()
1029 defer cancel2()
1030 sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1031 sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1032 ac.setSession(sess1)
1033 d.sessions[sess1.id] = sess1
1034 d.sessions[sess2.id] = sess2
1035
1036 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
1037 paintDone := make(chan struct{})
1038 go func() {
1039 d.paint(sess1, ac, false, nil)
1040 close(paintDone)
1041 }()
1042 <-enteredSend
1043
1044 switchDone := make(chan attachmentTransitionResult, 1)
1045 switchErr := make(chan error, 1)
1046 go func() {
1047 result, err := d.transitionAttachment(attachmentTransitionRequest{
1048 source: sess1,
1049 target: sess2,
1050 next: ac,
1051
1052 expectedTransport: ac.transportSnapshot(),
1053 ready: true,
1054 })
1055 switchDone <- result
1056 switchErr <- err
1057 }()
1058
1059 var result attachmentTransitionResult
1060 select {
1061 case result = <-switchDone:
1062 require.NoError(t, <-switchErr)
1063 case <-time.After(2 * time.Second):
1064 t.Fatal("session switch waited for in-flight transport send")
1065 }
1066 require.True(t, result.published.attachmentCurrent())
1067 select {
1068 case <-handoffAtSendMu:
1069 t.Fatal("output rebase ran during architecture publication")
1070 default:
1071 }
1072
1073 close(releaseSend)
1074 awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")
1075 for _, cleanup := range result.cleanups {
1076 cleanup.finish()
1077 }
1078}
1079
1080func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {
1081 rc := newRenderCoordinator(renderCoordinatorOptions{})
1082 viewer := &attachedClient{}
1083 newer := func(renderWake) {}
1084
1085 require.True(t, rc.subscribePreviewFor(viewer, 2, newer))
1086 require.False(t, rc.subscribePreviewFor(viewer, 1, func(renderWake) {}))
1087 rc.teardownPreviewFor(viewer, 1)
1088
1089 rc.mu.Lock()
1090 subscription, ok := rc.previewWakes[viewer]
1091 rc.mu.Unlock()
1092 require.True(t, ok)
1093 require.Equal(t, uint64(2), subscription.generation)
1094 require.NotNil(t, subscription.fn)
1095}
1096
1097func TestPickerPreviewPostSubscribeRevalidationKeepsNewerSubscription(t *testing.T) {
1098 d := newTestDaemon(t, nil, stubClock{})
1099 viewer := &attachedClient{}
1100 viewer.initOverlays()
1101 target := &session{}
1102 tab := &tab{}
1103 rc := newRenderCoordinator(renderCoordinatorOptions{})
1104
1105 viewer.overlays.pickerMu.Lock()
1106 viewer.overlays.pickerPreviewGeneration = 2
1107 viewer.overlays.pickerPreviewSession = target
1108 viewer.overlays.pickerPreview = tab
1109 viewer.overlays.pickerMu.Unlock()
1110 require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {}))
1111
1112 // This is the exact post-subscribe check from registerPreviewForSelection.
1113 // It represents a generation-1 subscriber resuming after generation 2 won.
1114 d.revalidatePreviewSubscription(viewer, rc, target, tab, 1)
1115
1116 rc.mu.Lock()
1117 subscription, ok := rc.previewWakes[viewer]
1118 rc.mu.Unlock()
1119 require.True(t, ok)
1120 require.Equal(t, uint64(2), subscription.generation)
1121}
1122
1123func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) {
1124 d := newTestDaemon(t, nil, stubClock{})
1125 viewer := &attachedClient{}
1126 viewer.initOverlays()
1127 newerSession := &session{}
1128 newerTab := &tab{}
1129 rc := newRenderCoordinator(renderCoordinatorOptions{})
1130
1131 viewer.overlays.pickerMu.Lock()
1132 viewer.overlays.pickerPreviewGeneration = 2
1133 viewer.overlays.pickerPreviewSession = newerSession
1134 viewer.overlays.pickerPreview = newerTab
1135 viewer.overlays.pickerMu.Unlock()
1136 require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {}))
1137
1138 require.False(t, d.clearPreviewGeneration(viewer, 1))
1139 require.True(t, pickerPreviewCurrent(viewer, newerSession, newerTab, 2))
1140 rc.mu.Lock()
1141 _, subscribed := rc.previewWakes[viewer]
1142 rc.mu.Unlock()
1143 require.True(t, subscribed)
1144}
1145
1146func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *testing.T) {
1147 palette := [16]renderer.RGB{}
1148 palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
1149 palette[10] = palette[2]
1150 accentTheme := themeui.Theme{
1151 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
1152 HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
1153 Palette: palette, PaletteKnown: 1<<2 | 1<<10,
1154 }
1155 indexedTheme := accentTheme
1156 indexedTheme.TrueColor = false
1157 neutralTheme := accentTheme
1158 neutralTheme.PaletteKnown = 0
1159 neutralTheme.SchemeKnown = false
1160
1161 defaults := domain.Defaults()
1162 paletteOff := defaults
1163 paletteOff.ThemePalette = false
1164 forcedDark := defaults
1165 forcedDark.Theme = domain.ThemeDark
1166 forcedLight := defaults
1167 forcedLight.Theme = domain.ThemeLight
1168 tests := []struct {
1169 name string
1170 raw themeui.Theme
1171 config domain.Config
1172 indexed bool
1173 }{
1174 {name: "truecolor accent", raw: accentTheme, config: defaults},
1175 {name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
1176 {name: "palette off", raw: accentTheme, config: paletteOff},
1177 {name: "forced dark", raw: accentTheme, config: forcedDark},
1178 {name: "forced light", raw: accentTheme, config: forcedLight},
1179 {name: "neutral fallback", raw: neutralTheme, config: defaults},
1180 }
1181
1182 for _, tt := range tests {
1183 t.Run(tt.name, func(t *testing.T) {
1184 d := newTestDaemon(t, nil, stubClock{})
1185 d.ApplyConfig(tt.config)
1186 applied := d.resolveAppliedTheme(tt.raw)
1187 state := capturedRenderState{
1188 theme: applied.Raw,
1189 styles: applied.Resolved.Styles,
1190 layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
1191 }
1192 snap := &overlayRenderSnapshot{
1193 pickerActive: true,
1194 pickerModel: picker.New([]picker.SessionView{
1195 {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
1196 {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
1197 }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}),
1198 }
1199
1200 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
1201
1202 require.False(t, state.styles.PickerDescription.HasBackgroundRGB)
1203 require.False(t, state.styles.PickerSeparator.HasBackgroundRGB)
1204 if tt.indexed {
1205 require.Equal(t, 2, state.styles.PickerDescription.Foreground)
1206 require.Equal(t, 2, state.styles.PickerSeparator.Foreground)
1207 }
1208
1209 inner := state.overlays.picker.inner
1210 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
1211 require.Equal(t, picker.LayoutHorizontal, layout.Mode)
1212 require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text keeps a muted foreground on the terminal background")
1213 require.True(t, inner.At(layout.List.Width-1, 3).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")
1214 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
1215 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
1216 cell := inner.At(x, y)
1217 require.Equal(t, '│', cell.Rune)
1218 require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator keeps a foreground-only style on the terminal background")
1219 }
1220 }
1221 })
1222 }
1223}
1224
1225func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
1226 model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
1227 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
1228 cases := []struct {
1229 name string
1230 size domain.Size
1231 marker rune
1232 wantMode picker.LayoutMode
1233 wantSep rune
1234 wantMarker bool
1235 }{
1236 {name: "horizontal", size: domain.Size{Cols: 100, Rows: 40}, marker: '◆', wantMode: picker.LayoutHorizontal, wantSep: '│', wantMarker: true},
1237 {name: "stacked", size: domain.Size{Cols: 40, Rows: 20}, marker: '◇', wantMode: picker.LayoutStacked, wantSep: '─', wantMarker: true},
1238 {name: "list only", size: domain.Size{Cols: 24, Rows: 11}, marker: '●', wantMode: picker.LayoutListOnly},
1239 }
1240 for _, tt := range cases {
1241 t.Run(tt.name, func(t *testing.T) {
1242 state.layout.area = domain.Rect{Width: tt.size.Cols, Height: tt.size.Rows - 2}
1243 snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model, previewTab: nil}
1244 state.preview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: tt.marker, Style: renderer.DefaultStyle()}}}}
1245
1246 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
1247
1248 inner := state.overlays.picker.inner
1249 layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
1250 require.Equal(t, tt.wantMode, layout.Mode)
1251 got := strings.Join(frameRows(inner), "\n")
1252 if tt.wantMarker {
1253 require.Contains(t, got, string(tt.marker))
1254 } else {
1255 require.NotContains(t, got, "◆")
1256 require.NotContains(t, got, "◇")
1257 require.NotContains(t, got, "●")
1258 }
1259 if tt.wantSep == 0 {
1260 require.NotContains(t, got, "│")
1261 require.NotContains(t, got, "─")
1262 return
1263 }
1264 if tt.wantSep == '│' {
1265 require.NotContains(t, got, "─")
1266 } else {
1267 require.NotContains(t, got, "│")
1268 }
1269 for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
1270 for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
1271 cell := inner.At(x, y)
1272 require.Equal(t, tt.wantSep, cell.Rune)
1273 require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator must use the cached PickerSeparator role")
1274 }
1275 }
1276 })
1277 }
1278}
1279
1280func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) {
1281 withScrollback := newPickerPreviewTabWithHistoryRows(t, 10_000)
1282 require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())
1283
1284 preview := snapshotPickerPreview(withScrollback)
1285 require.Equal(t, 10, preview.Width)
1286 require.Equal(t, 3, preview.Height)
1287 require.Len(t, preview.Rows, preview.Height)
1288 cells := 0
1289 for _, row := range preview.Rows {
1290 require.Len(t, row, preview.Width)
1291 cells += len(row)
1292 }
1293 require.Equal(t, preview.Width*preview.Height, cells)
1294 got := strings.Join(func() []string {
1295 rows := make([]string, len(preview.Rows))
1296 for i, row := range preview.Rows {
1297 rows[i] = rowText(row)
1298 }
1299 return rows
1300 }(), "\n")
1301 require.Contains(t, got, "NOW")
1302 require.NotContains(t, got, "history-only-marker")
1303}
1304
1305func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab {
1306 t.Helper()
1307 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
1308 p := tb.focusedPane()
1309 for range historyRows {
1310 appendHistoryRow(t, p.screen.History(), testRow("history-only-marker"))
1311 }
1312 p.screen.Write([]byte("NOW"))
1313 return tb
1314}
1315
1316func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
1317 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
1318 p := tb.focusedPane()
1319 p.screen.Write([]byte("focused"))
1320
1321 preview := snapshotPickerPreview(tb)
1322
1323 require.Equal(t, 10, preview.Width)
1324 require.Equal(t, 3, preview.Height)
1325 require.Equal(t, 'f', preview.Rows[0][0].Rune)
1326 require.Equal(t, 'o', preview.Rows[0][1].Rune)
1327}
1328
1329func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
1330 tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})
1331 left := tb.focusedPane()
1332 left.title.processName = "one"
1333 left.screen.Write([]byte("L"))
1334 rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
1335 rightTop.title.processName = "two"
1336 rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})
1337 rightBottom.title.processName = "three"
1338 rightBottom.screen.Write([]byte("R"))
1339
1340 tb.mu.Lock()
1341 tb.panes[rightTop.id] = rightTop
1342 tb.panes[rightBottom.id] = rightBottom
1343 tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
1344 layout.NewLeaf(left.id),
1345 {Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(rightTop.id), layout.NewLeaf(rightBottom.id)}, Expanded: rightBottom.id},
1346 }}
1347 tb.tree.Focus = rightBottom.id
1348 tb.mu.Unlock()
1349
1350 preview := snapshotPickerPreview(tb)
1351
1352 require.Equal(t, 41, preview.Width)
1353 require.Equal(t, 5, preview.Height)
1354 require.Equal(t, 'L', preview.Rows[0][0].Rune, "left pane content should remain visible")
1355 require.Equal(t, '│', preview.Rows[0][20].Rune, "split divider should be included")
1356 require.Equal(t, "two", rowText(preview.Rows[0][21:24]), "collapsed stack title bar should be included")
1357 require.Equal(t, 'R', preview.Rows[1][21].Rune, "expanded stack member draws no title bar; its content starts where the title bar used to be")
1358}
1359
1360func TestPickerModalGeometry(t *testing.T) {
1361 base := domain.Size{Cols: 100, Rows: 40}
1362
1363 require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds)
1364 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
1365}
1366
1367func TestPickerResizeRecomposesModal(t *testing.T) {
1368 p, releasePTY := newBlockingPTY(t)
1369 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1370 defer releasePTY()
1371
1372 d.enterPicker(sess, ac)
1373 awaitFrame(t, sends, ports.MsgOutput)
1374 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
1375 // Picker recomposition is the relevant event here; do not depend on a real
1376 // resize-idle timer in this synchronous rendering test.
1377 d.paint(sess, ac, false, nil)
1378
1379 out := awaitFrame(t, sends, ports.MsgOutput)
1380 msg, err := ports.UnmarshalOutput(out.Payload)
1381 require.NoError(t, err)
1382 require.Contains(t, string(msg.Data), "┌")
1383 require.Contains(t, string(msg.Data), "Sessions")
1384}
1385
1386func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
1387 sz := domain.Size{Cols: 80, Rows: 24}
1388 p1, release1 := newBlockingPTY(t)
1389 defer release1()
1390 p2, release2 := newBlockingPTY(t)
1391 defer release2()
1392 var opens [][]string
1393 f := portsmocks.NewMockPTYFactory(t)
1394 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1395 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1396 func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1397 opens = append(opens, append([]string(nil), env...))
1398 if len(opens) == 1 {
1399 return p1, nil
1400 }
1401 return p2, nil
1402 },
1403 ).Twice()
1404 floating := newQuietPTY()
1405 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1406 return got != normalSize && got.Valid()
1407 })).Return(floating, nil).Once()
1408 d := newTestDaemon(t, f, stubClock{})
1409 d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
1410 tr := portsmocks.NewMockTransport(t)
1411 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1412 tr.EXPECT().Close().Return(nil).Maybe()
1413 sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1414 require.NoError(t, err)
1415 d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})
1416 got := ac.currentSession()
1417 require.NotNil(t, got)
1418 got.mu.Lock()
1419 require.True(t, got.terminal.TrueColor)
1420 got.mu.Unlock()
1421 require.Len(t, opens, 2)
1422 require.Contains(t, opens[1], "TERM=xterm-direct")
1423 require.Contains(t, opens[1], "COLORTERM=truecolor")
1424 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1425 require.Eventually(t, func() bool {
1426 tb := testAttachmentTab(got)
1427 if tb == nil {
1428 return false
1429 }
1430 tb.mu.Lock()
1431 defer tb.mu.Unlock()
1432 return tb.floating.pane != nil && tb.floating.pane.pty == floating
1433 }, time.Second, 5*time.Millisecond)
1434 _ = d.killSession(got, ports.ReasonSessionKilled, false)
1435 release1()
1436 release2()
1437 d.sessWg.Wait()
1438 select {
1439 case <-floating.done:
1440 default:
1441 t.Fatal("floating prewarm PTY was not closed")
1442 }
1443}
1444
1445// TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut drives
1446// Enter on a stopped picker entry whose restore spawn fails (mock PTY Open
1447// error). The failure must reach the user as a NoticeSessionUnavailable
1448// notice and toast, and the client must remain attached to its origin
1449// session rather than being left in limbo.
1450func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *testing.T) {
1451 p, release := newBlockingPTY(t)
1452 defer release()
1453 d, from, ac, sends := newManualSessionWithPTYs(t, p)
1454 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
1455 cause := errors.New("open failed")
1456 ptys := portsmocks.NewMockPTYFactory(t)
1457 ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
1458 d.ptys = ptys
1459
1460 d.enterPicker(from, ac)
1461 awaitFrame(t, sends, ports.MsgOutput)
1462 d.handleInput(from, ac, []byte("j"))
1463 awaitFrame(t, sends, ports.MsgOutput)
1464 d.handleInput(from, ac, []byte("\r"))
1465 awaitFrame(t, sends, ports.MsgOutput)
1466
1467 history := d.notices.history()
1468 require.Len(t, history, 1, "failed stopped-session restore must record exactly one notice")
1469 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
1470 require.Equal(t, domain.NoticeError, history[0].Severity)
1471
1472 toasts := awaitToastCount(t, ac, 1)
1473 require.Equal(t, domain.NoticeSessionUnavailable, toasts[0].Code)
1474
1475 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
1476}
1477
1478// TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow pins the post-kill
1479// contract: the cursor stays on the row index the killed session occupied, so
1480// it lands on whatever session took its place — clamped to the new last
1481// selectable row when the bottom session was killed. Session names drive the
1482// picker's alphabetical tie-break (every session here shares mruAt 0), which is
1483// what puts the victim in the middle or at the end of the list.
1484func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1485 tests := []struct {
1486 name string
1487 currentName string
1488 otherName string
1489 targetName string
1490 nav []byte // keys walked from the attached session's active tab onto the victim
1491 wantSession domain.SessionID
1492 wantTabID domain.TabStableID
1493 wantTabIndex int
1494 }{
1495 {
1496 // Rows: [a-other, other-tab, m-target, target-tab, z-current, current-first, current-active].
1497 // Killing target-tab (row 3) promotes z-current's header to row 2, so
1498 // row 3 becomes current-first — not the attached active tab.
1499 name: "middle session killed selects the row taking its place",
1500 currentName: "z-current", otherName: "a-other", targetName: "m-target",
1501 nav: []byte("kk"),
1502 wantSession: "current", wantTabID: "current-first", wantTabIndex: 0,
1503 },
1504 {
1505 // Rows: [a-current, current-first, current-active, m-other, other-tab, z-target, target-tab].
1506 // Killing the bottom row (6) clamps to the new last selectable row.
1507 name: "last session killed selects the new last row",
1508 currentName: "a-current", otherName: "m-other", targetName: "z-target",
1509 nav: []byte("jj"),
1510 wantSession: "other", wantTabID: "other-tab", wantTabIndex: 0,
1511 },
1512 }
1513 for _, tt := range tests {
1514 t.Run(tt.name, func(t *testing.T) {
1515 d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1516 defer releaseAll(currentReleases)
1517 current.id, current.name = "current", tt.currentName
1518 current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1519 selectTestAttachmentTab(current, 1)
1520 delete(d.sessions, domain.SessionID("manual"))
1521 d.sessions[current.id] = current
1522
1523 otherPTY, releaseOther := newBlockingPTY(t)
1524 defer releaseOther()
1525 otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1526 otherTab.stableID = "other-tab"
1527 other := &session{sessionCore: sessionCore{id: "other", name: tt.otherName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1528 d.sessions[other.id] = other
1529
1530 targetPTY, releaseTarget := newBlockingPTY(t)
1531 defer releaseTarget()
1532 targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1533 targetTab.stableID = "target-tab"
1534 target := &session{sessionCore: sessionCore{id: "target", name: tt.targetName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1535 d.sessions[target.id] = target
1536
1537 d.enterPicker(current, ac)
1538 for _, key := range tt.nav {
1539 d.handlePickerInput(ac, []byte{key})
1540 }
1541 ac.overlays.pickerMu.Lock()
1542 selected, ok := ac.overlays.picker.Selected()
1543 ac.overlays.pickerMu.Unlock()
1544 require.True(t, ok)
1545 require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1546
1547 d.handlePickerInput(ac, []byte("x"))
1548
1549 d.mu.Lock()
1550 _, targetStillLive := d.sessions[target.id]
1551 d.mu.Unlock()
1552 require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1553
1554 ac.overlays.pickerMu.Lock()
1555 selected, ok = ac.overlays.picker.Selected()
1556 ac.overlays.pickerMu.Unlock()
1557 require.True(t, ok)
1558 require.Equal(t, tt.wantSession, selected.Session)
1559 require.Equal(t, tt.wantTabID, selected.TabID)
1560 require.Equal(t, tt.wantTabIndex, selected.TabIndex)
1561 })
1562 }
1563}
1564
1565func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
1566 d, current, ac, _, currentReleases := newManualTabSession(t, 1)
1567 defer releaseAll(currentReleases)
1568 current.id, current.name = "current", "z-current"
1569 delete(d.sessions, domain.SessionID("manual"))
1570 d.sessions[current.id] = current
1571
1572 targetPTY, releaseTarget := newBlockingPTY(t)
1573 defer releaseTarget()
1574 targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1575 target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1576 d.sessions[target.id] = target
1577 current.mu.Lock()
1578 current.registerAttachmentLocked(ac)
1579 current.mu.Unlock()
1580 require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
1581
1582 d.enterPicker(current, ac)
1583 d.handlePickerInput(ac, []byte("k"))
1584 ac.overlays.pickerMu.Lock()
1585 selected, ok := ac.overlays.picker.Selected()
1586 ac.overlays.pickerMu.Unlock()
1587 require.True(t, ok)
1588 require.Equal(t, target.id, selected.Session)
1589
1590 token := current.attachmentToken(ac, ac.transport())
1591 token.lease = current.renderCoordinator().attachmentLease(ac)
1592 ac.publishAttachmentCapability(token)
1593 effect, admitted := ac.beginAttachmentEffect(token)
1594 require.True(t, admitted)
1595 d.handlePickerInput(ac, []byte("x"), effect)
1596
1597 d.mu.Lock()
1598 _, targetStillLive := d.sessions[target.id]
1599 d.mu.Unlock()
1600 require.False(t, targetStillLive, "effect-backed picker deletion must remove the selected session")
1601 require.True(t, ac.overlays.pickerActive(), "successful deletion refreshes rather than closes the picker")
1602}
1603
1604func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {
1605 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
1606 defer releaseAll(releases)
1607 cause := errors.New("snapshot delete refused")
1608 WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d)
1609 target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
1610 store, _ := newMockStore(t)
1611 WithStore(t, store)(d)
1612 target.mu.Lock()
1613 if target.incarnation == (domain.IncarnationID{}) {
1614 target.incarnation = domain.IncarnationID{1}
1615 }
1616 record := target.persistRecordLocked(1)
1617 target.mu.Unlock()
1618 require.NoError(t, d.catalogue.Create(record))
1619
1620 d.enterPicker(from, ac)
1621 awaitFrame(t, sends, ports.MsgOutput)
1622 // MRU-desc order is from(current), recent, older; "j" moves onto "recent".
1623 d.handleInput(from, ac, []byte("j"))
1624 awaitFrame(t, sends, ports.MsgOutput)
1625 d.handleInput(from, ac, []byte("x"))
1626
1627 history := d.notices.history()
1628 require.Len(t, history, 1, "the refused purge must be reported exactly once")
1629 require.Equal(t, domain.NoticeInternal, history[0].Code)
1630 require.Contains(t, history[0].Details, cause.Error())
1631 require.Len(t, awaitToastCount(t, ac, 1), 1, "the refusal must produce one visible notice")
1632
1633 require.Same(t, from, ac.currentSession(), "purging another session must preserve the attachment")
1634 require.True(t, ac.overlays.pickerActive(), "x refreshes the picker instead of closing it")
1635 d.mu.Lock()
1636 _, stillActive := d.sessions[target.id]
1637 d.mu.Unlock()
1638 require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure")
1639}
1640
1641// TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1642// drives 'x' on a stopped picker entry whose persisted-record delete fails
1643// (mock store error). The failure must reach the user as a
1644// NoticePersistDelete notice and toast, and the entry must remain listed
1645// after the picker refreshes rather than silently vanishing from the daemon's
1646// bookkeeping while still present on disk.
1647func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry(t *testing.T) {
1648 p, release := newBlockingPTY(t)
1649 defer release()
1650 d, from, ac, sends := newManualSessionWithPTYs(t, p)
1651 otherPTY, releaseOther := newBlockingPTY(t)
1652 defer releaseOther()
1653 otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel)
1654 otherTab.stableID = "other-tab"
1655 other := &session{sessionCore: sessionCore{id: "other", name: "a-other", ephemeral: true}, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1656 d.sessions[other.id] = other
1657 cause := errors.New("delete failed")
1658 store, state := newMockStore(t)
1659 WithStore(t, store)(d)
1660 record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7}
1661 require.NoError(t, d.catalogue.Create(record))
1662 state.mu.Lock()
1663 state.deleteErr = func(string) error { return cause }
1664 state.mu.Unlock()
1665 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: record.IncarnationID, record: record}
1666
1667 d.enterPicker(from, ac)
1668 awaitFrame(t, sends, ports.MsgOutput)
1669 d.handleInput(from, ac, []byte("j"))
1670 awaitFrame(t, sends, ports.MsgOutput)
1671 d.handleInput(from, ac, []byte("x"))
1672 awaitFrame(t, sends, ports.MsgOutput)
1673
1674 history := d.notices.history()
1675 require.NotEmpty(t, history, "failed persisted-record delete must record a notice")
1676 require.Equal(t, domain.NoticePersistDelete, history[0].Code)
1677 require.Equal(t, domain.NoticeError, history[0].Severity)
1678
1679 toasts := awaitToastCount(t, ac, 1)
1680 require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
1681
1682 d.mu.Lock()
1683 stopped, retained := d.stopped["stopped"]
1684 d.mu.Unlock()
1685 require.True(t, retained, "failed deletion must retain the reserved name")
1686 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
1687
1688 from.mu.Lock()
1689 activeTabID := testAttachmentTabLocked(from).stableID
1690 from.mu.Unlock()
1691 ac.overlays.pickerMu.Lock()
1692 selected, selectedOK := ac.overlays.picker.Selected()
1693 ac.overlays.pickerMu.Unlock()
1694 require.True(t, selectedOK)
1695 require.Equal(t, from.id, selected.Session, "failed x refresh resets navigation to the attached session")
1696 require.Equal(t, domain.TabStableID(activeTabID), selected.TabID, "failed x refresh follows the attached session's stable active tab")
1697}