detached 9453c6d

refactor(daemon): picker actions return errors and surface notices

switchToTarget, resumeStoppedAndSwitchLocked, and killPickerTarget now
return error instead of a bare bool/void so a failed restore or delete
surfaces as a NoticeSessionUnavailable/NoticePersistDelete toast via
handlePickerInput's reportError funnel, instead of only a log line.
commit
9453c6dc412babdf2ae428f56398584f9bbd8286
author
brice · · 2026-07-20 18:05 UTC
parent
cd08227
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +116 -25
M internal/usecase/daemon/attention.go +1 -1
@@ -58,7 +58,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
5858 if !ok {
5959 return
6060 }
61- d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
61+ _ = d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
6262 }
6363
6464 func oldestAttentionTab(sess *session) (int, bool) {
@@ -58,7 +58,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
58 if !ok {58 if !ok {
59 return59 return
60 }60 }
61- d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})61+ _ = d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
62 }62 }
63 63
64 func oldestAttentionTab(sess *session) (int, bool) {64 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/palette.go +2 -2
@@ -270,7 +270,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
270270 } else {
271271 target.Stopped = true
272272 }
273- if !d.switchToTarget(sess, ac, target) {
273+ if err := d.switchToTarget(sess, ac, target); err != nil {
274274 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
275275 d.invalidateRender(sess, ac, true, "palette.go")
276276 return
@@ -510,7 +510,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
510510 if e.d.beforeRecentSessionHandoff != nil {
511511 e.d.beforeRecentSessionHandoff()
512512 }
513- if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) {
513+ if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil {
514514 return command.ErrInvalidArguments
515515 }
516516 return nil
@@ -270,7 +270,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
270 } else {270 } else {
271 target.Stopped = true271 target.Stopped = true
272 }272 }
273- if !d.switchToTarget(sess, ac, target) {273+ if err := d.switchToTarget(sess, ac, target); err != nil {
274 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")274 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
275 d.invalidateRender(sess, ac, true, "palette.go")275 d.invalidateRender(sess, ac, true, "palette.go")
276 return276 return
@@ -510,7 +510,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
510 if e.d.beforeRecentSessionHandoff != nil {510 if e.d.beforeRecentSessionHandoff != nil {
511 e.d.beforeRecentSessionHandoff()511 e.d.beforeRecentSessionHandoff()
512 }512 }
513- if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) {513+ if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil {
514 return command.ErrInvalidArguments514 return command.ErrInvalidArguments
515 }515 }
516 return nil516 return nil
M internal/usecase/daemon/picker.go +20 -14
@@ -122,7 +122,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
122122 target, ok := ac.overlays.picker.Selected()
123123 ac.overlays.pickerMu.Unlock()
124124 if ok {
125- d.killPickerTarget(target)
125+ if err := d.killPickerTarget(target); err != nil {
126+ d.reportError(sess, err)
127+ }
126128 }
127129 d.refreshPicker(ac)
128130 d.invalidateRender(sess, ac, true, "picker.go")
@@ -171,7 +173,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
171173 d.closePicker(ac)
172174 }
173175 if switchTarget && ok {
174- d.switchToTarget(sess, ac, target)
176+ if err := d.switchToTarget(sess, ac, target); err != nil {
177+ d.reportError(sess, err)
178+ }
175179 return
176180 }
177181 if exit || changed {
@@ -394,19 +398,20 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
394398 // switchToTarget resolves named lifecycle targets and commits their transition
395399 // while d.mu is held. A named palette result is allowed to cross an
396400 // active/stopped transition, but it never follows a same-name replacement.
397-func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool {
401+func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
398402 d.mu.Lock()
399403 var (
400404 targetSess *session
401405 old *attachedClient
402406 cleanups []renderLifecycleCleanup
403407 switched bool
408+ cause error
404409 )
405410 if target.Name != "" {
406411 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
407412 if ok {
408413 if isStopped {
409- targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
414+ targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
410415 } else {
411416 // The snapshot ID may describe the stopped representation. The
412417 // locked name/lifecycle resolver chose this active incarnation.
@@ -422,7 +427,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
422427
423428 if !switched {
424429 d.invalidateRender(from, ac, true, "picker.go")
425- return false
430+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
426431 }
427432 finishRenderLifecycleCleanups(cleanups)
428433 if old != nil && old != ac {
@@ -437,7 +442,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
437442 } else {
438443 d.firstPaint(targetSess, ac, ac.size)
439444 }
440- return true
445+ return nil
441446 }
442447
443448 // resolveNamedLifecycleTargetLocked chooses exactly one current representation
@@ -517,11 +522,11 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
517522 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
518523 // the handoff while d.mu is held. Creation failure leaves the source client
519524 // and stopped record untouched.
520-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) {
525+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {
521526 from.mu.Lock()
522527 if from.client != ac {
523528 from.mu.Unlock()
524- return nil, nil, false
529+ return nil, nil, false, nil
525530 }
526531 term := from.terminal
527532 env := copyEnvironment(from.env)
@@ -530,7 +535,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
530535 if err != nil {
531536 from.mu.Unlock()
532537 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
533- return nil, nil, false
538+ return nil, nil, false, err
534539 }
535540 from.client = nil
536541 ac.setSession(nil)
@@ -543,7 +548,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
543548 targetSess.mu.Unlock()
544549 d.touchMRU(targetSess)
545550 ac.recordPreviousSession(from)
546- return targetSess, cleanups, true
551+ return targetSess, cleanups, true, nil
547552 }
548553
549554 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
@@ -574,7 +579,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
574579 switched bool
575580 )
576581 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
577- targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
582+ targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
578583 }
579584 d.mu.Unlock()
580585 if !switched {
@@ -586,7 +591,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
586591 return true
587592 }
588593
589-func (d *Daemon) killPickerTarget(target picker.Target) {
594+func (d *Daemon) killPickerTarget(target picker.Target) error {
590595 if target.Stopped {
591596 d.mu.Lock()
592597 stopped, ok := d.stopped[target.Name]
@@ -594,12 +599,12 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
594599 if err := d.persist.Delete(target.Name); err != nil {
595600 d.mu.Unlock()
596601 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
597- return
602+ return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
598603 }
599604 delete(d.stopped, target.Name)
600605 }
601606 d.mu.Unlock()
602- return
607+ return nil
603608 }
604609 d.mu.Lock()
605610 targetSess := d.sessions[target.Session]
@@ -607,6 +612,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
607612 if targetSess != nil {
608613 _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
609614 }
615+ return nil
610616 }
611617
612618 func (d *Daemon) refreshPicker(ac *attachedClient) {
@@ -122,7 +122,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
122 target, ok := ac.overlays.picker.Selected()122 target, ok := ac.overlays.picker.Selected()
123 ac.overlays.pickerMu.Unlock()123 ac.overlays.pickerMu.Unlock()
124 if ok {124 if ok {
125- d.killPickerTarget(target)125+ if err := d.killPickerTarget(target); err != nil {
126+ d.reportError(sess, err)
127+ }
126 }128 }
127 d.refreshPicker(ac)129 d.refreshPicker(ac)
128 d.invalidateRender(sess, ac, true, "picker.go")130 d.invalidateRender(sess, ac, true, "picker.go")
@@ -171,7 +173,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
171 d.closePicker(ac)173 d.closePicker(ac)
172 }174 }
173 if switchTarget && ok {175 if switchTarget && ok {
174- d.switchToTarget(sess, ac, target)176+ if err := d.switchToTarget(sess, ac, target); err != nil {
177+ d.reportError(sess, err)
178+ }
175 return179 return
176 }180 }
177 if exit || changed {181 if exit || changed {
@@ -394,19 +398,20 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
394 // switchToTarget resolves named lifecycle targets and commits their transition398 // switchToTarget resolves named lifecycle targets and commits their transition
395 // while d.mu is held. A named palette result is allowed to cross an399 // while d.mu is held. A named palette result is allowed to cross an
396 // active/stopped transition, but it never follows a same-name replacement.400 // active/stopped transition, but it never follows a same-name replacement.
397-func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool {401+func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
398 d.mu.Lock()402 d.mu.Lock()
399 var (403 var (
400 targetSess *session404 targetSess *session
401 old *attachedClient405 old *attachedClient
402 cleanups []renderLifecycleCleanup406 cleanups []renderLifecycleCleanup
403 switched bool407 switched bool
408+ cause error
404 )409 )
405 if target.Name != "" {410 if target.Name != "" {
406 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)411 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
407 if ok {412 if ok {
408 if isStopped {413 if isStopped {
409- targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)414+ targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
410 } else {415 } else {
411 // The snapshot ID may describe the stopped representation. The416 // The snapshot ID may describe the stopped representation. The
412 // locked name/lifecycle resolver chose this active incarnation.417 // locked name/lifecycle resolver chose this active incarnation.
@@ -422,7 +427,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
422 427
423 if !switched {428 if !switched {
424 d.invalidateRender(from, ac, true, "picker.go")429 d.invalidateRender(from, ac, true, "picker.go")
425- return false430+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
426 }431 }
427 finishRenderLifecycleCleanups(cleanups)432 finishRenderLifecycleCleanups(cleanups)
428 if old != nil && old != ac {433 if old != nil && old != ac {
@@ -437,7 +442,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
437 } else {442 } else {
438 d.firstPaint(targetSess, ac, ac.size)443 d.firstPaint(targetSess, ac, ac.size)
439 }444 }
440- return true445+ return nil
441 }446 }
442 447
443 // resolveNamedLifecycleTargetLocked chooses exactly one current representation448 // resolveNamedLifecycleTargetLocked chooses exactly one current representation
@@ -517,11 +522,11 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
517 // resumeStoppedAndSwitchLocked creates the stopped representation and commits522 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
518 // the handoff while d.mu is held. Creation failure leaves the source client523 // the handoff while d.mu is held. Creation failure leaves the source client
519 // and stopped record untouched.524 // and stopped record untouched.
520-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) {525+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {
521 from.mu.Lock()526 from.mu.Lock()
522 if from.client != ac {527 if from.client != ac {
523 from.mu.Unlock()528 from.mu.Unlock()
524- return nil, nil, false529+ return nil, nil, false, nil
525 }530 }
526 term := from.terminal531 term := from.terminal
527 env := copyEnvironment(from.env)532 env := copyEnvironment(from.env)
@@ -530,7 +535,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
530 if err != nil {535 if err != nil {
531 from.mu.Unlock()536 from.mu.Unlock()
532 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)537 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
533- return nil, nil, false538+ return nil, nil, false, err
534 }539 }
535 from.client = nil540 from.client = nil
536 ac.setSession(nil)541 ac.setSession(nil)
@@ -543,7 +548,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
543 targetSess.mu.Unlock()548 targetSess.mu.Unlock()
544 d.touchMRU(targetSess)549 d.touchMRU(targetSess)
545 ac.recordPreviousSession(from)550 ac.recordPreviousSession(from)
546- return targetSess, cleanups, true551+ return targetSess, cleanups, true, nil
547 }552 }
548 553
549 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {554 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
@@ -574,7 +579,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
574 switched bool579 switched bool
575 )580 )
576 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {581 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
577- targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)582+ targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
578 }583 }
579 d.mu.Unlock()584 d.mu.Unlock()
580 if !switched {585 if !switched {
@@ -586,7 +591,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
586 return true591 return true
587 }592 }
588 593
589-func (d *Daemon) killPickerTarget(target picker.Target) {594+func (d *Daemon) killPickerTarget(target picker.Target) error {
590 if target.Stopped {595 if target.Stopped {
591 d.mu.Lock()596 d.mu.Lock()
592 stopped, ok := d.stopped[target.Name]597 stopped, ok := d.stopped[target.Name]
@@ -594,12 +599,12 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
594 if err := d.persist.Delete(target.Name); err != nil {599 if err := d.persist.Delete(target.Name); err != nil {
595 d.mu.Unlock()600 d.mu.Unlock()
596 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)601 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
597- return602+ return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
598 }603 }
599 delete(d.stopped, target.Name)604 delete(d.stopped, target.Name)
600 }605 }
601 d.mu.Unlock()606 d.mu.Unlock()
602- return607+ return nil
603 }608 }
604 d.mu.Lock()609 d.mu.Lock()
605 targetSess := d.sessions[target.Session]610 targetSess := d.sessions[target.Session]
@@ -607,6 +612,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
607 if targetSess != nil {612 if targetSess != nil {
608 _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)613 _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
609 }614 }
615+ return nil
610 }616 }
611 617
612 func (d *Daemon) refreshPicker(ac *attachedClient) {618 func (d *Daemon) refreshPicker(ac *attachedClient) {
M internal/usecase/daemon/picker_test.go +87 -2
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "strings"
67 "testing"
78 "time"
@@ -22,6 +23,18 @@ import (
2223
2324 // --- test doubles -----------------------------------------------------------
2425
26+// failingDeleteStore is a ports.Store whose Delete always fails, used to
27+// exercise the picker's kill-target error path without disturbing the
28+// generated mock's default Delete expectation.
29+type failingDeleteStore struct{ err error }
30+
31+func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }
32+func (failingDeleteStore) Set([]byte, []byte) error { return nil }
33+func (s failingDeleteStore) Delete([]byte) error { return s.err }
34+func (failingDeleteStore) Range(func(k, v []byte) bool) {}
35+func (failingDeleteStore) Sync() error { return nil }
36+func (failingDeleteStore) Close() error { return nil }
37+
2538 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
2639 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
2740 tb.ctx, tb.cancel = ctx, cancel
@@ -140,7 +153,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
140153 replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
141154 d.sessions[replacement.id] = replacement
142155
143- require.False(t, d.switchToTarget(current, ac, target))
156+ require.Error(t, d.switchToTarget(current, ac, target))
144157 require.Same(t, current, ac.currentSession())
145158 }
146159
@@ -152,7 +165,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
152165 d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
153166 current.client = &attachedClient{}
154167
155- require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
168+ require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
156169 require.Equal(t, 0, current.active)
157170 }
158171
@@ -945,3 +958,75 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
945958 t.Fatal("floating prewarm PTY was not closed")
946959 }
947960 }
961+
962+// TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut drives
963+// Enter on a stopped picker entry whose restore spawn fails (mock PTY Open
964+// error). The failure must reach the user as a NoticeSessionUnavailable
965+// notice and toast, and the client must remain attached to its origin
966+// session rather than being left in limbo.
967+func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *testing.T) {
968+ p, release := newBlockingPTY(t)
969+ defer release()
970+ d, from, ac, sends := newManualSessionWithPTYs(t, p)
971+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
972+ cause := errors.New("open failed")
973+ ptys := portsmocks.NewMockPTYFactory(t)
974+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
975+ d.ptys = ptys
976+
977+ d.enterPicker(from, ac)
978+ awaitFrame(t, sends, ports.MsgOutput)
979+ d.handleInput(from, ac, []byte("j"))
980+ awaitFrame(t, sends, ports.MsgOutput)
981+ d.handleInput(from, ac, []byte("\r"))
982+ awaitFrame(t, sends, ports.MsgOutput)
983+
984+ history := d.notices.history()
985+ require.NotEmpty(t, history, "failed stopped-session restore must record a notice")
986+ require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
987+ require.Equal(t, domain.NoticeError, history[0].Severity)
988+
989+ toasts := awaitToastCount(t, ac, 1)
990+ require.Equal(t, domain.NoticeSessionUnavailable, toasts[0].Code)
991+
992+ require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
993+}
994+
995+// TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
996+// drives 'x' on a stopped picker entry whose persisted-record delete fails
997+// (mock store error). The failure must reach the user as a
998+// NoticePersistDelete notice and toast, and the entry must remain listed
999+// after the picker refreshes rather than silently vanishing from the daemon's
1000+// bookkeeping while still present on disk.
1001+func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry(t *testing.T) {
1002+ p, release := newBlockingPTY(t)
1003+ defer release()
1004+ d, from, ac, sends := newManualSessionWithPTYs(t, p)
1005+ cause := errors.New("delete failed")
1006+ WithStore(failingDeleteStore{err: cause})(d)
1007+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
1008+
1009+ d.enterPicker(from, ac)
1010+ awaitFrame(t, sends, ports.MsgOutput)
1011+ d.handleInput(from, ac, []byte("j"))
1012+ awaitFrame(t, sends, ports.MsgOutput)
1013+ d.handleInput(from, ac, []byte("x"))
1014+ awaitFrame(t, sends, ports.MsgOutput)
1015+
1016+ history := d.notices.history()
1017+ require.NotEmpty(t, history, "failed persisted-record delete must record a notice")
1018+ require.Equal(t, domain.NoticePersistDelete, history[0].Code)
1019+ require.Equal(t, domain.NoticeError, history[0].Severity)
1020+
1021+ toasts := awaitToastCount(t, ac, 1)
1022+ require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
1023+
1024+ views, _ := d.pickerViews(from)
1025+ var stillListed bool
1026+ for _, v := range views {
1027+ if v.Stopped && v.Name == "stopped" {
1028+ stillListed = true
1029+ }
1030+ }
1031+ require.True(t, stillListed, "entry must remain listed after refreshPicker when the delete failed")
1032+}
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "strings"6 "strings"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -22,6 +23,18 @@ import (
22 23
23 // --- test doubles -----------------------------------------------------------24 // --- test doubles -----------------------------------------------------------
24 25
26+// failingDeleteStore is a ports.Store whose Delete always fails, used to
27+// exercise the picker's kill-target error path without disturbing the
28+// generated mock's default Delete expectation.
29+type failingDeleteStore struct{ err error }
30+
31+func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }
32+func (failingDeleteStore) Set([]byte, []byte) error { return nil }
33+func (s failingDeleteStore) Delete([]byte) error { return s.err }
34+func (failingDeleteStore) Range(func(k, v []byte) bool) {}
35+func (failingDeleteStore) Sync() error { return nil }
36+func (failingDeleteStore) Close() error { return nil }
37+
25 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {38 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
26 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})39 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
27 tb.ctx, tb.cancel = ctx, cancel40 tb.ctx, tb.cancel = ctx, cancel
@@ -140,7 +153,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
140 replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}153 replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
141 d.sessions[replacement.id] = replacement154 d.sessions[replacement.id] = replacement
142 155
143- require.False(t, d.switchToTarget(current, ac, target))156+ require.Error(t, d.switchToTarget(current, ac, target))
144 require.Same(t, current, ac.currentSession())157 require.Same(t, current, ac.currentSession())
145 }158 }
146 159
@@ -152,7 +165,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
152 d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)165 d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
153 current.client = &attachedClient{}166 current.client = &attachedClient{}
154 167
155- require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))168+ require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
156 require.Equal(t, 0, current.active)169 require.Equal(t, 0, current.active)
157 }170 }
158 171
@@ -945,3 +958,75 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
945 t.Fatal("floating prewarm PTY was not closed")958 t.Fatal("floating prewarm PTY was not closed")
946 }959 }
947 }960 }
961+
962+// TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut drives
963+// Enter on a stopped picker entry whose restore spawn fails (mock PTY Open
964+// error). The failure must reach the user as a NoticeSessionUnavailable
965+// notice and toast, and the client must remain attached to its origin
966+// session rather than being left in limbo.
967+func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *testing.T) {
968+ p, release := newBlockingPTY(t)
969+ defer release()
970+ d, from, ac, sends := newManualSessionWithPTYs(t, p)
971+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
972+ cause := errors.New("open failed")
973+ ptys := portsmocks.NewMockPTYFactory(t)
974+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
975+ d.ptys = ptys
976+
977+ d.enterPicker(from, ac)
978+ awaitFrame(t, sends, ports.MsgOutput)
979+ d.handleInput(from, ac, []byte("j"))
980+ awaitFrame(t, sends, ports.MsgOutput)
981+ d.handleInput(from, ac, []byte("\r"))
982+ awaitFrame(t, sends, ports.MsgOutput)
983+
984+ history := d.notices.history()
985+ require.NotEmpty(t, history, "failed stopped-session restore must record a notice")
986+ require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
987+ require.Equal(t, domain.NoticeError, history[0].Severity)
988+
989+ toasts := awaitToastCount(t, ac, 1)
990+ require.Equal(t, domain.NoticeSessionUnavailable, toasts[0].Code)
991+
992+ require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
993+}
994+
995+// TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
996+// drives 'x' on a stopped picker entry whose persisted-record delete fails
997+// (mock store error). The failure must reach the user as a
998+// NoticePersistDelete notice and toast, and the entry must remain listed
999+// after the picker refreshes rather than silently vanishing from the daemon's
1000+// bookkeeping while still present on disk.
1001+func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry(t *testing.T) {
1002+ p, release := newBlockingPTY(t)
1003+ defer release()
1004+ d, from, ac, sends := newManualSessionWithPTYs(t, p)
1005+ cause := errors.New("delete failed")
1006+ WithStore(failingDeleteStore{err: cause})(d)
1007+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
1008+
1009+ d.enterPicker(from, ac)
1010+ awaitFrame(t, sends, ports.MsgOutput)
1011+ d.handleInput(from, ac, []byte("j"))
1012+ awaitFrame(t, sends, ports.MsgOutput)
1013+ d.handleInput(from, ac, []byte("x"))
1014+ awaitFrame(t, sends, ports.MsgOutput)
1015+
1016+ history := d.notices.history()
1017+ require.NotEmpty(t, history, "failed persisted-record delete must record a notice")
1018+ require.Equal(t, domain.NoticePersistDelete, history[0].Code)
1019+ require.Equal(t, domain.NoticeError, history[0].Severity)
1020+
1021+ toasts := awaitToastCount(t, ac, 1)
1022+ require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
1023+
1024+ views, _ := d.pickerViews(from)
1025+ var stillListed bool
1026+ for _, v := range views {
1027+ if v.Stopped && v.Name == "stopped" {
1028+ stillListed = true
1029+ }
1030+ }
1031+ require.True(t, stillListed, "entry must remain listed after refreshPicker when the delete failed")
1032+}
M internal/usecase/daemon/session_back.go +1 -1
@@ -14,7 +14,7 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
1414 d.invalidateRender(current, ac, true, "session_back.go")
1515 return
1616 }
17- d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
17+ _ = d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
1818 }
1919
2020 // clearPreviousSessionIf clears target only if it has not been replaced since
@@ -14,7 +14,7 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
14 d.invalidateRender(current, ac, true, "session_back.go")14 d.invalidateRender(current, ac, true, "session_back.go")
15 return15 return
16 }16 }
17- d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})17+ _ = d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
18 }18 }
19 19
20 // clearPreviousSessionIf clears target only if it has not been replaced since20 // clearPreviousSessionIf clears target only if it has not been replaced since
M internal/usecase/daemon/session_test.go +5 -5
@@ -1079,7 +1079,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
10791079 require.NoError(t, d.renameSession(second, "named"))
10801080 require.NotEqual(t, staleCreatedAt, second.createdAt)
10811081
1082- require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))
1082+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))
10831083 require.Same(t, from, ac.currentSession())
10841084 releaseSecond()
10851085 d.sessWg.Wait()
@@ -1729,7 +1729,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17291729 target.createdAt = 22
17301730 expected := int64(21)
17311731
1732- require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
1732+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
17331733 require.Same(t, from, ac.currentSession())
17341734 })
17351735
@@ -1743,7 +1743,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17431743 expected := int64(31)
17441744 d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}
17451745
1746- require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1746+ require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
17471747 require.Equal(t, "target", ac.currentSession().name)
17481748 require.Equal(t, expected, ac.currentSession().createdAt)
17491749 })
@@ -1755,7 +1755,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17551755 target.createdAt = 41
17561756 expected := int64(41)
17571757
1758- require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1758+ require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
17591759 require.Same(t, target, ac.currentSession())
17601760 })
17611761
@@ -1766,7 +1766,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17661766 d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}
17671767 expected := int64(51)
17681768
1769- require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1769+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
17701770 require.Same(t, from, ac.currentSession())
17711771 })
17721772 }
@@ -1079,7 +1079,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
1079 require.NoError(t, d.renameSession(second, "named"))1079 require.NoError(t, d.renameSession(second, "named"))
1080 require.NotEqual(t, staleCreatedAt, second.createdAt)1080 require.NotEqual(t, staleCreatedAt, second.createdAt)
1081 1081
1082- require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))1082+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))
1083 require.Same(t, from, ac.currentSession())1083 require.Same(t, from, ac.currentSession())
1084 releaseSecond()1084 releaseSecond()
1085 d.sessWg.Wait()1085 d.sessWg.Wait()
@@ -1729,7 +1729,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
1729 target.createdAt = 221729 target.createdAt = 22
1730 expected := int64(21)1730 expected := int64(21)
1731 1731
1732- require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))1732+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
1733 require.Same(t, from, ac.currentSession())1733 require.Same(t, from, ac.currentSession())
1734 })1734 })
1735 1735
@@ -1743,7 +1743,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
1743 expected := int64(31)1743 expected := int64(31)
1744 d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}1744 d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}
1745 1745
1746- require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))1746+ require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1747 require.Equal(t, "target", ac.currentSession().name)1747 require.Equal(t, "target", ac.currentSession().name)
1748 require.Equal(t, expected, ac.currentSession().createdAt)1748 require.Equal(t, expected, ac.currentSession().createdAt)
1749 })1749 })
@@ -1755,7 +1755,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
1755 target.createdAt = 411755 target.createdAt = 41
1756 expected := int64(41)1756 expected := int64(41)
1757 1757
1758- require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))1758+ require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1759 require.Same(t, target, ac.currentSession())1759 require.Same(t, target, ac.currentSession())
1760 })1760 })
1761 1761
@@ -1766,7 +1766,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
1766 d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}1766 d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}
1767 expected := int64(51)1767 expected := int64(51)
1768 1768
1769- require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))1769+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1770 require.Same(t, from, ac.currentSession())1770 require.Same(t, from, ac.currentSession())
1771 })1771 })
1772 }1772 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close