detached 316c07f

feat(session): toggle the previously active session

commit
316c07f32b94413fef6911d9f1a5940c66d8593b
author
brice · · 2026-07-11 11:41 UTC
parent
e94bd47
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +76 -757
M internal/usecase/command/command.go +0 -1
@@ -20,7 +20,6 @@ type Context interface {
2020 NextTab() error
2121 PrevTab() error
2222 BackSession() error
23- ForwardSession() error
2423 Detach() error
2524 EnterVisualMode() error
2625 RenameSession() error
@@ -20,7 +20,6 @@ type Context interface {
20 NextTab() error20 NextTab() error
21 PrevTab() error21 PrevTab() error
22 BackSession() error22 BackSession() error
23- ForwardSession() error
24 Detach() error23 Detach() error
25 EnterVisualMode() error24 EnterVisualMode() error
26 RenameSession() error25 RenameSession() error
M internal/usecase/command/context_mock_test.go +0 -44
@@ -519,50 +519,6 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
519519 return _c
520520 }
521521
522-// ForwardSession provides a mock function for the type MockContext
523-func (_mock *MockContext) ForwardSession() error {
524- ret := _mock.Called()
525-
526- if len(ret) == 0 {
527- panic("no return value specified for ForwardSession")
528- }
529-
530- var r0 error
531- if returnFunc, ok := ret.Get(0).(func() error); ok {
532- r0 = returnFunc()
533- } else {
534- r0 = ret.Error(0)
535- }
536- return r0
537-}
538-
539-// MockContext_ForwardSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForwardSession'
540-type MockContext_ForwardSession_Call struct {
541- *mock.Call
542-}
543-
544-// ForwardSession is a helper method to define mock.On call
545-func (_e *MockContext_Expecter) ForwardSession() *MockContext_ForwardSession_Call {
546- return &MockContext_ForwardSession_Call{Call: _e.mock.On("ForwardSession")}
547-}
548-
549-func (_c *MockContext_ForwardSession_Call) Run(run func()) *MockContext_ForwardSession_Call {
550- _c.Call.Run(func(args mock.Arguments) {
551- run()
552- })
553- return _c
554-}
555-
556-func (_c *MockContext_ForwardSession_Call) Return(err error) *MockContext_ForwardSession_Call {
557- _c.Call.Return(err)
558- return _c
559-}
560-
561-func (_c *MockContext_ForwardSession_Call) RunAndReturn(run func() error) *MockContext_ForwardSession_Call {
562- _c.Call.Return(run)
563- return _c
564-}
565-
566522 // NextTab provides a mock function for the type MockContext
567523 func (_mock *MockContext) NextTab() error {
568524 ret := _mock.Called()
@@ -519,50 +519,6 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
519 return _c519 return _c
520 }520 }
521 521
522-// ForwardSession provides a mock function for the type MockContext
523-func (_mock *MockContext) ForwardSession() error {
524- ret := _mock.Called()
525-
526- if len(ret) == 0 {
527- panic("no return value specified for ForwardSession")
528- }
529-
530- var r0 error
531- if returnFunc, ok := ret.Get(0).(func() error); ok {
532- r0 = returnFunc()
533- } else {
534- r0 = ret.Error(0)
535- }
536- return r0
537-}
538-
539-// MockContext_ForwardSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForwardSession'
540-type MockContext_ForwardSession_Call struct {
541- *mock.Call
542-}
543-
544-// ForwardSession is a helper method to define mock.On call
545-func (_e *MockContext_Expecter) ForwardSession() *MockContext_ForwardSession_Call {
546- return &MockContext_ForwardSession_Call{Call: _e.mock.On("ForwardSession")}
547-}
548-
549-func (_c *MockContext_ForwardSession_Call) Run(run func()) *MockContext_ForwardSession_Call {
550- _c.Call.Run(func(args mock.Arguments) {
551- run()
552- })
553- return _c
554-}
555-
556-func (_c *MockContext_ForwardSession_Call) Return(err error) *MockContext_ForwardSession_Call {
557- _c.Call.Return(err)
558- return _c
559-}
560-
561-func (_c *MockContext_ForwardSession_Call) RunAndReturn(run func() error) *MockContext_ForwardSession_Call {
562- _c.Call.Return(run)
563- return _c
564-}
565-
566 // NextTab provides a mock function for the type MockContext522 // NextTab provides a mock function for the type MockContext
567 func (_mock *MockContext) NextTab() error {523 func (_mock *MockContext) NextTab() error {
568 ret := _mock.Called()524 ret := _mock.Called()
M internal/usecase/command/registry.go +1 -2
@@ -22,8 +22,7 @@ func Registry() []Command {
2222 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
2323 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
2424 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
25- {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Step backward through recent-session history", Run: func(ctx Context) error { return ctx.BackSession() }},
26- {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Step forward through recent-session history", Run: func(ctx Context) error { return ctx.ForwardSession() }},
25+ {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context) error { return ctx.BackSession() }},
2726 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
2827 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
2928 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
@@ -22,8 +22,7 @@ func Registry() []Command {
22 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},22 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
23 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},23 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
24 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},24 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
25- {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Step backward through recent-session history", Run: func(ctx Context) error { return ctx.BackSession() }},25+ {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context) error { return ctx.BackSession() }},
26- {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Step forward through recent-session history", Run: func(ctx Context) error { return ctx.ForwardSession() }},
27 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
28 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
29 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
M internal/usecase/command/registry_test.go +1 -2
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -86,7 +86,6 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
8686 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
8787 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
8888 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
89- {code: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().Return(nil).Once() }},
9089 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
9190 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
9291 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
@@ -7,7 +7,7 @@ import (
7 7
8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9 commands := Registry()9 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
11 11
12 if len(commands) != len(wantCodes) {12 if len(commands) != len(wantCodes) {
13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -86,7 +86,6 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
86 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},86 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
87 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},87 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
88 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},88 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
89- {code: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().Return(nil).Once() }},
90 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},89 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
91 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},90 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
92 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},91 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
M internal/usecase/daemon/client.go +11 -20
@@ -59,12 +59,9 @@ type attachedClient struct {
5959 theme themeui.Theme
6060 clientTheme themeui.Theme
6161 lastCursor cursorOut
62- recentNav recentSessionNavigator
63- // historyNavMu protects historyNav and historyNavTimer. When paint needs
64- // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
65- historyNavMu sync.Mutex
66- historyNav historyNavDisplay
67- historyNavTimer pendingByteTimer
62+ // previousSession is guarded independently. It is retained through temporary
63+ // setSession(nil) hand-offs and cleared only on terminal teardown.
64+ previousSession Guarded[*session]
6865 linkMu sync.Mutex
6966 sendMu sync.Mutex
7067 }
@@ -125,22 +122,12 @@ func (ac *attachedClient) initOverlays() {
125122
126123 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
127124
128-func (ac *attachedClient) setSession(sess *session) {
129- if sess == nil {
130- ac.clearHistoryNav()
131- }
132- ac.sess.Set(sess)
133-}
125+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
134126
135-func (ac *attachedClient) clearHistoryNav() {
136- if ac == nil {
137- return
127+func (ac *attachedClient) clearPreviousSession() {
128+ if ac != nil {
129+ ac.previousSession.Set(nil)
138130 }
139- ac.historyNavMu.Lock()
140- ac.historyNav.gen++
141- ac.historyNavTimer.stop()
142- ac.historyNav.clear()
143- ac.historyNavMu.Unlock()
144131 }
145132
146133 func (ac *attachedClient) getTheme() themeui.Theme {
@@ -435,7 +422,9 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
435422 return
436423 }
437424 old.cancelResizePaint()
425+
438426 d.unregisterPreview(old)
427+ old.clearPreviousSession()
439428 old.setSession(nil)
440429 d.log.Info("client displaced")
441430 // Async + bounded: a dead or wedged old client must not stall the new
@@ -554,7 +543,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
554543 d.log.Info("client parked", "session", sess.name)
555544 return
556545 }
546+
557547 d.resetScreenDefaultColors(sess)
548+ ac.clearPreviousSession()
558549 if explicit {
559550 // Synchronous so the ack is delivered before the transport closes
560551 // (the client is actively awaiting it), but deadline-bounded so a
@@ -59,12 +59,9 @@ type attachedClient struct {
59 theme themeui.Theme59 theme themeui.Theme
60 clientTheme themeui.Theme60 clientTheme themeui.Theme
61 lastCursor cursorOut61 lastCursor cursorOut
62- recentNav recentSessionNavigator62+ // previousSession is guarded independently. It is retained through temporary
63- // historyNavMu protects historyNav and historyNavTimer. When paint needs63+ // setSession(nil) hand-offs and cleared only on terminal teardown.
64- // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.64+ previousSession Guarded[*session]
65- historyNavMu sync.Mutex
66- historyNav historyNavDisplay
67- historyNavTimer pendingByteTimer
68 linkMu sync.Mutex65 linkMu sync.Mutex
69 sendMu sync.Mutex66 sendMu sync.Mutex
70 }67 }
@@ -125,22 +122,12 @@ func (ac *attachedClient) initOverlays() {
125 122
126 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }123 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
127 124
128-func (ac *attachedClient) setSession(sess *session) {125+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
129- if sess == nil {
130- ac.clearHistoryNav()
131- }
132- ac.sess.Set(sess)
133-}
134 126
135-func (ac *attachedClient) clearHistoryNav() {127+func (ac *attachedClient) clearPreviousSession() {
136- if ac == nil {128+ if ac != nil {
137- return129+ ac.previousSession.Set(nil)
138 }130 }
139- ac.historyNavMu.Lock()
140- ac.historyNav.gen++
141- ac.historyNavTimer.stop()
142- ac.historyNav.clear()
143- ac.historyNavMu.Unlock()
144 }131 }
145 132
146 func (ac *attachedClient) getTheme() themeui.Theme {133 func (ac *attachedClient) getTheme() themeui.Theme {
@@ -435,7 +422,9 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
435 return422 return
436 }423 }
437 old.cancelResizePaint()424 old.cancelResizePaint()
425+
438 d.unregisterPreview(old)426 d.unregisterPreview(old)
427+ old.clearPreviousSession()
439 old.setSession(nil)428 old.setSession(nil)
440 d.log.Info("client displaced")429 d.log.Info("client displaced")
441 // Async + bounded: a dead or wedged old client must not stall the new430 // Async + bounded: a dead or wedged old client must not stall the new
@@ -554,7 +543,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
554 d.log.Info("client parked", "session", sess.name)543 d.log.Info("client parked", "session", sess.name)
555 return544 return
556 }545 }
546+
557 d.resetScreenDefaultColors(sess)547 d.resetScreenDefaultColors(sess)
548+ ac.clearPreviousSession()
558 if explicit {549 if explicit {
559 // Synchronous so the ack is delivered before the transport closes550 // Synchronous so the ack is delivered before the transport closes
560 // (the client is actively awaiting it), but deadline-bounded so a551 // (the client is actively awaiting it), but deadline-bounded so a
M internal/usecase/daemon/input_test.go +22 -305
@@ -17,6 +17,7 @@ import (
1717 scopy "github.com/bnema/vev/internal/usecase/copy"
1818 "github.com/bnema/vev/internal/usecase/keys"
1919 "github.com/bnema/vev/internal/usecase/layout"
20+ "github.com/bnema/vev/internal/usecase/picker"
2021 )
2122
2223 // --- test doubles -----------------------------------------------------------
@@ -415,323 +416,39 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
415416 }
416417 }
417418
418-func TestPaletteBackForwardSession(t *testing.T) {
419+func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {
419420 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
420421 defer releaseAll(releases)
421422 recent := d.sessions[domain.SessionID("recent")]
422- older := d.sessions[domain.SessionID("older")]
423423
424- runPaletteCommand(t, d, current, ac, "BSK")
425- require.Same(t, recent, ac.currentSession())
426-
427- runPaletteCommand(t, d, recent, ac, "BSK")
428- require.Same(t, older, ac.currentSession())
429-
430- runPaletteCommand(t, d, older, ac, "FSK")
431- require.Same(t, recent, ac.currentSession())
432-
433- runPaletteCommand(t, d, recent, ac, "FSK")
434- require.Same(t, current, ac.currentSession())
435-}
436-
437-func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
438- t.Run("forward from current does not wrap", func(t *testing.T) {
439- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
440- defer releaseAll(releases)
441-
442- runPaletteCommand(t, d, current, ac, "FSK")
443- require.Same(t, current, ac.currentSession())
444- })
445-
446- t.Run("back with no MRU does not move", func(t *testing.T) {
447- p, release := newBlockingPTY(t)
448- defer release()
449- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
450-
451- runPaletteCommand(t, d, sess, ac, "BSK")
452- require.Same(t, sess, ac.currentSession())
453- })
454-
455- t.Run("back at oldest does not wrap", func(t *testing.T) {
456- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
457- defer releaseAll(releases)
458- older := d.sessions[domain.SessionID("older")]
459-
460- runPaletteCommand(t, d, current, ac, "BSK")
424+ // Picker-style successful transition records its origin.
425+ d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})
426+ for _, sess := range []*session{current, recent, current} {
461427 runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
462- require.Same(t, older, ac.currentSession())
463-
464- runPaletteCommand(t, d, older, ac, "BSK")
465- require.Same(t, older, ac.currentSession())
466- })
467-}
468-
469-func TestHistoryNavDisplay(t *testing.T) {
470- ids := []domain.SessionID{"current", "recent", "older"}
471- var h historyNavDisplay
472-
473- h.set(ids, 1, 7)
474- ids[1] = "mutated"
475- require.True(t, h.active())
476- require.Equal(t, []domain.SessionID{domain.SessionID("current"), domain.SessionID("recent"), domain.SessionID("older")}, h.ids)
477- require.Equal(t, 1, h.index)
478- require.Equal(t, uint64(7), h.gen)
479-
480- h.index = -1
481- require.False(t, h.active())
482- h.index = len(h.ids)
483- require.False(t, h.active())
484-
485- h.clear()
486- require.False(t, h.active())
487- require.Empty(t, h.ids)
488- require.Zero(t, h.index)
489- require.Equal(t, uint64(7), h.gen)
490-}
491-
492-func TestHistoryNavigationCommandDetectionUsesSlug(t *testing.T) {
493- require.True(t, isHistoryNavigationCommand("back-session"))
494- require.True(t, isHistoryNavigationCommand("forward-session"))
495- require.False(t, isHistoryNavigationCommand("BSK"))
496- require.False(t, isHistoryNavigationCommand("FSK"))
497- require.False(t, isHistoryNavigationCommand("next-tab"))
498-}
499-
500-func TestHistoryNavActivation(t *testing.T) {
501- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
502- defer releaseAll(releases)
503- recent := d.sessions[domain.SessionID("recent")]
504- older := d.sessions[domain.SessionID("older")]
505-
506- runPaletteCommand(t, d, current, ac, "BSK")
507- require.Same(t, recent, ac.currentSession())
508- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
509- require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
510-
511- runPaletteCommand(t, d, recent, ac, "BSK")
512- require.Same(t, older, ac.currentSession())
513- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
514-
515- runPaletteCommand(t, d, older, ac, "FSK")
516- require.Same(t, recent, ac.currentSession())
517- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
518-}
519-
520-func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
521- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
522- defer releaseAll(releases)
523-
524- runPaletteCommand(t, d, current, ac, "FSK")
525-
526- require.Same(t, current, ac.currentSession())
527- ac.historyNavMu.Lock()
528- active := ac.historyNav.active()
529- ac.historyNavMu.Unlock()
530- require.False(t, active)
531-}
532-
533-func TestHistoryNavTimeoutExpires(t *testing.T) {
534- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
535- defer releaseAll(releases)
536- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
537- d.clock = clock
538-
539- runPaletteCommand(t, d, current, ac, "BSK")
540- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
541- timer := <-clock.timers
542- drainOutputFrames(sends)
543- timer.fire()
544-
545- require.Eventually(t, func() bool {
546- ac.historyNavMu.Lock()
547- defer ac.historyNavMu.Unlock()
548- return !ac.historyNav.active()
549- }, time.Second, time.Millisecond)
550- require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
428+ require.Same(t, sess, ac.currentSession())
429+ }
551430 }
552431
553-func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {
432+func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
554433 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
555434 defer releaseAll(releases)
556- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}
557- d.clock = clock
558-
559- runPaletteCommand(t, d, current, ac, "BSK")
560- staleTimer := <-clock.timers
561- ac.clearHistoryNav()
562- runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")
563- <-clock.timers
564-
565- staleTimer.fire()
566-
567- require.Eventually(t, func() bool {
568- ac.historyNavMu.Lock()
569- defer ac.historyNavMu.Unlock()
570- return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
571- }, time.Second, time.Millisecond)
572-}
573-
574-func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
575- t.Run("detach stops stale timer repaint", func(t *testing.T) {
576- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
577- defer releaseAll(releases)
578- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
579- d.clock = clock
580-
581- runPaletteCommand(t, d, current, ac, "BSK")
582- timer := <-clock.timers
583- recent := ac.currentSession()
584- d.clientGone(recent, ac, ac.transport(), true)
585- drainOutputFrames(sends)
586- timer.fire()
587-
588- requireNoHistoryNav(t, ac)
589- require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
590- })
591-
592- t.Run("replacement stops stale timer repaint", func(t *testing.T) {
593- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
594- defer releaseAll(releases)
595- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
596- d.clock = clock
597-
598- runPaletteCommand(t, d, current, ac, "BSK")
599- timer := <-clock.timers
600- d.detachReplacedClient(ac)
601- drainOutputFrames(sends)
602- timer.fire()
603-
604- requireNoHistoryNav(t, ac)
605- require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
606- })
607-}
608-
609-func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
610- t.Run("invalid frozen trail clears", func(t *testing.T) {
611- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
612- defer releaseAll(releases)
613-
614- runPaletteCommand(t, d, current, ac, "BSK")
615- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
616- d.mu.Lock()
617- delete(d.sessions, domain.SessionID("older"))
618- d.mu.Unlock()
619-
620- require.Nil(t, d.historyNavBarState(ac))
621- requireNoHistoryNav(t, ac)
622- })
623-
624- t.Run("non history palette command clears frozen trail", func(t *testing.T) {
625- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
626- defer releaseAll(releases)
627-
628- runPaletteCommand(t, d, current, ac, "BSK")
629- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
630- runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
631-
632- requireNoHistoryNav(t, ac)
633- })
634-}
635-
636-func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
637- t.Helper()
638- ac.historyNavMu.Lock()
639- defer ac.historyNavMu.Unlock()
640- require.True(t, ac.historyNav.active())
641- require.Equal(t, want, ac.historyNav.ids)
642- require.Equal(t, wantIndex, ac.historyNav.index)
643-}
644-
645-func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
646- t.Helper()
647- ac.historyNavMu.Lock()
648- defer ac.historyNavMu.Unlock()
649- require.False(t, ac.historyNav.active())
650-}
651-
652-func drainOutputFrames(sends chan ports.Frame) int {
653- count := 0
654- for {
655- select {
656- case f := <-sends:
657- if f.Type == ports.MsgOutput {
658- count++
659- }
660- default:
661- return count
662- }
435+ for _, tc := range []struct {
436+ name string
437+ prepare func()
438+ }{
439+ {name: "no target", prepare: func() {}},
440+ {name: "stale target", prepare: func() { ac.previousSession.Set(&session{id: "gone"}) }},
441+ {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
442+ } {
443+ t.Run(tc.name, func(t *testing.T) {
444+ ac.clearPreviousSession()
445+ tc.prepare()
446+ runPaletteCommand(t, d, current, ac, "BSK")
447+ require.Same(t, current, ac.currentSession())
448+ })
663449 }
664450 }
665451
666-type historyNavTestClock struct {
667- timers chan *historyNavTestTimer
668-}
669-
670-func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
671-
672-func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
673- t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
674- c.timers <- t
675- return t
676-}
677-
678-type historyNavTestTimer struct {
679- ch chan time.Time
680-}
681-
682-func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
683-func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
684-func (t *historyNavTestTimer) Stop() bool { return true }
685-func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
686-
687-func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
688- t.Run("forward skips a killed intermediate session", func(t *testing.T) {
689- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
690- defer releaseAll(releases)
691- recent := d.sessions[domain.SessionID("recent")]
692- older := d.sessions[domain.SessionID("older")]
693-
694- runPaletteCommand(t, d, current, ac, "BSK")
695- runPaletteCommand(t, d, recent, ac, "BSK")
696- require.Same(t, older, ac.currentSession())
697-
698- d.mu.Lock()
699- delete(d.sessions, recent.id)
700- d.mu.Unlock()
701-
702- runPaletteCommand(t, d, older, ac, "FSK")
703- require.Same(t, current, ac.currentSession())
704- })
705-
706- t.Run("back from current refreshes newly recent sessions", func(t *testing.T) {
707- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
708- defer releaseAll(releases)
709- p, release := newBlockingPTY(t)
710- defer release()
711- newer := &session{id: "newer", name: "newer", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p, domain.Size{Cols: 80, Rows: 23})}}
712- newer.mruAt.Store(25)
713- d.mu.Lock()
714- d.sessions[newer.id] = newer
715- d.mu.Unlock()
716-
717- runPaletteCommand(t, d, current, ac, "BSK")
718- require.Same(t, newer, ac.currentSession())
719- })
720-
721- t.Run("failed switch does not advance navigator index", func(t *testing.T) {
722- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
723- defer releaseAll(releases)
724- current.mu.Lock()
725- current.client = nil
726- current.mu.Unlock()
727-
728- runPaletteCommand(t, d, current, ac, "BSK")
729-
730- require.Same(t, current, ac.currentSession())
731- require.Zero(t, ac.recentNav.index)
732- })
733-}
734-
735452 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
736453 t.Helper()
737454 p1, release1 := newBlockingPTY(t)
@@ -17,6 +17,7 @@ import (
17 scopy "github.com/bnema/vev/internal/usecase/copy"17 scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/keys"18 "github.com/bnema/vev/internal/usecase/keys"
19 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
20+ "github.com/bnema/vev/internal/usecase/picker"
20 )21 )
21 22
22 // --- test doubles -----------------------------------------------------------23 // --- test doubles -----------------------------------------------------------
@@ -415,323 +416,39 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
415 }416 }
416 }417 }
417 418
418-func TestPaletteBackForwardSession(t *testing.T) {419+func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {
419 d, current, ac, _, releases := newRecentNavigationTestSessions(t)420 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
420 defer releaseAll(releases)421 defer releaseAll(releases)
421 recent := d.sessions[domain.SessionID("recent")]422 recent := d.sessions[domain.SessionID("recent")]
422- older := d.sessions[domain.SessionID("older")]
423 423
424- runPaletteCommand(t, d, current, ac, "BSK")424+ // Picker-style successful transition records its origin.
425- require.Same(t, recent, ac.currentSession())425+ d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})
426-426+ for _, sess := range []*session{current, recent, current} {
427- runPaletteCommand(t, d, recent, ac, "BSK")
428- require.Same(t, older, ac.currentSession())
429-
430- runPaletteCommand(t, d, older, ac, "FSK")
431- require.Same(t, recent, ac.currentSession())
432-
433- runPaletteCommand(t, d, recent, ac, "FSK")
434- require.Same(t, current, ac.currentSession())
435-}
436-
437-func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
438- t.Run("forward from current does not wrap", func(t *testing.T) {
439- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
440- defer releaseAll(releases)
441-
442- runPaletteCommand(t, d, current, ac, "FSK")
443- require.Same(t, current, ac.currentSession())
444- })
445-
446- t.Run("back with no MRU does not move", func(t *testing.T) {
447- p, release := newBlockingPTY(t)
448- defer release()
449- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
450-
451- runPaletteCommand(t, d, sess, ac, "BSK")
452- require.Same(t, sess, ac.currentSession())
453- })
454-
455- t.Run("back at oldest does not wrap", func(t *testing.T) {
456- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
457- defer releaseAll(releases)
458- older := d.sessions[domain.SessionID("older")]
459-
460- runPaletteCommand(t, d, current, ac, "BSK")
461 runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")427 runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
462- require.Same(t, older, ac.currentSession())428+ require.Same(t, sess, ac.currentSession())
463-429+ }
464- runPaletteCommand(t, d, older, ac, "BSK")
465- require.Same(t, older, ac.currentSession())
466- })
467-}
468-
469-func TestHistoryNavDisplay(t *testing.T) {
470- ids := []domain.SessionID{"current", "recent", "older"}
471- var h historyNavDisplay
472-
473- h.set(ids, 1, 7)
474- ids[1] = "mutated"
475- require.True(t, h.active())
476- require.Equal(t, []domain.SessionID{domain.SessionID("current"), domain.SessionID("recent"), domain.SessionID("older")}, h.ids)
477- require.Equal(t, 1, h.index)
478- require.Equal(t, uint64(7), h.gen)
479-
480- h.index = -1
481- require.False(t, h.active())
482- h.index = len(h.ids)
483- require.False(t, h.active())
484-
485- h.clear()
486- require.False(t, h.active())
487- require.Empty(t, h.ids)
488- require.Zero(t, h.index)
489- require.Equal(t, uint64(7), h.gen)
490-}
491-
492-func TestHistoryNavigationCommandDetectionUsesSlug(t *testing.T) {
493- require.True(t, isHistoryNavigationCommand("back-session"))
494- require.True(t, isHistoryNavigationCommand("forward-session"))
495- require.False(t, isHistoryNavigationCommand("BSK"))
496- require.False(t, isHistoryNavigationCommand("FSK"))
497- require.False(t, isHistoryNavigationCommand("next-tab"))
498-}
499-
500-func TestHistoryNavActivation(t *testing.T) {
501- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
502- defer releaseAll(releases)
503- recent := d.sessions[domain.SessionID("recent")]
504- older := d.sessions[domain.SessionID("older")]
505-
506- runPaletteCommand(t, d, current, ac, "BSK")
507- require.Same(t, recent, ac.currentSession())
508- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
509- require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
510-
511- runPaletteCommand(t, d, recent, ac, "BSK")
512- require.Same(t, older, ac.currentSession())
513- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
514-
515- runPaletteCommand(t, d, older, ac, "FSK")
516- require.Same(t, recent, ac.currentSession())
517- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
518-}
519-
520-func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
521- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
522- defer releaseAll(releases)
523-
524- runPaletteCommand(t, d, current, ac, "FSK")
525-
526- require.Same(t, current, ac.currentSession())
527- ac.historyNavMu.Lock()
528- active := ac.historyNav.active()
529- ac.historyNavMu.Unlock()
530- require.False(t, active)
531-}
532-
533-func TestHistoryNavTimeoutExpires(t *testing.T) {
534- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
535- defer releaseAll(releases)
536- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
537- d.clock = clock
538-
539- runPaletteCommand(t, d, current, ac, "BSK")
540- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
541- timer := <-clock.timers
542- drainOutputFrames(sends)
543- timer.fire()
544-
545- require.Eventually(t, func() bool {
546- ac.historyNavMu.Lock()
547- defer ac.historyNavMu.Unlock()
548- return !ac.historyNav.active()
549- }, time.Second, time.Millisecond)
550- require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
551 }430 }
552 431
553-func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {432+func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
554 d, current, ac, _, releases := newRecentNavigationTestSessions(t)433 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
555 defer releaseAll(releases)434 defer releaseAll(releases)
556- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}435+ for _, tc := range []struct {
557- d.clock = clock436+ name string
558-437+ prepare func()
559- runPaletteCommand(t, d, current, ac, "BSK")438+ }{
560- staleTimer := <-clock.timers439+ {name: "no target", prepare: func() {}},
561- ac.clearHistoryNav()440+ {name: "stale target", prepare: func() { ac.previousSession.Set(&session{id: "gone"}) }},
562- runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")441+ {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
563- <-clock.timers442+ } {
564-443+ t.Run(tc.name, func(t *testing.T) {
565- staleTimer.fire()444+ ac.clearPreviousSession()
566-445+ tc.prepare()
567- require.Eventually(t, func() bool {446+ runPaletteCommand(t, d, current, ac, "BSK")
568- ac.historyNavMu.Lock()447+ require.Same(t, current, ac.currentSession())
569- defer ac.historyNavMu.Unlock()448+ })
570- return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
571- }, time.Second, time.Millisecond)
572-}
573-
574-func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
575- t.Run("detach stops stale timer repaint", func(t *testing.T) {
576- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
577- defer releaseAll(releases)
578- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
579- d.clock = clock
580-
581- runPaletteCommand(t, d, current, ac, "BSK")
582- timer := <-clock.timers
583- recent := ac.currentSession()
584- d.clientGone(recent, ac, ac.transport(), true)
585- drainOutputFrames(sends)
586- timer.fire()
587-
588- requireNoHistoryNav(t, ac)
589- require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
590- })
591-
592- t.Run("replacement stops stale timer repaint", func(t *testing.T) {
593- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
594- defer releaseAll(releases)
595- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
596- d.clock = clock
597-
598- runPaletteCommand(t, d, current, ac, "BSK")
599- timer := <-clock.timers
600- d.detachReplacedClient(ac)
601- drainOutputFrames(sends)
602- timer.fire()
603-
604- requireNoHistoryNav(t, ac)
605- require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
606- })
607-}
608-
609-func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
610- t.Run("invalid frozen trail clears", func(t *testing.T) {
611- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
612- defer releaseAll(releases)
613-
614- runPaletteCommand(t, d, current, ac, "BSK")
615- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
616- d.mu.Lock()
617- delete(d.sessions, domain.SessionID("older"))
618- d.mu.Unlock()
619-
620- require.Nil(t, d.historyNavBarState(ac))
621- requireNoHistoryNav(t, ac)
622- })
623-
624- t.Run("non history palette command clears frozen trail", func(t *testing.T) {
625- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
626- defer releaseAll(releases)
627-
628- runPaletteCommand(t, d, current, ac, "BSK")
629- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
630- runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
631-
632- requireNoHistoryNav(t, ac)
633- })
634-}
635-
636-func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
637- t.Helper()
638- ac.historyNavMu.Lock()
639- defer ac.historyNavMu.Unlock()
640- require.True(t, ac.historyNav.active())
641- require.Equal(t, want, ac.historyNav.ids)
642- require.Equal(t, wantIndex, ac.historyNav.index)
643-}
644-
645-func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
646- t.Helper()
647- ac.historyNavMu.Lock()
648- defer ac.historyNavMu.Unlock()
649- require.False(t, ac.historyNav.active())
650-}
651-
652-func drainOutputFrames(sends chan ports.Frame) int {
653- count := 0
654- for {
655- select {
656- case f := <-sends:
657- if f.Type == ports.MsgOutput {
658- count++
659- }
660- default:
661- return count
662- }
663 }449 }
664 }450 }
665 451
666-type historyNavTestClock struct {
667- timers chan *historyNavTestTimer
668-}
669-
670-func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
671-
672-func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
673- t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
674- c.timers <- t
675- return t
676-}
677-
678-type historyNavTestTimer struct {
679- ch chan time.Time
680-}
681-
682-func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
683-func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
684-func (t *historyNavTestTimer) Stop() bool { return true }
685-func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
686-
687-func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
688- t.Run("forward skips a killed intermediate session", func(t *testing.T) {
689- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
690- defer releaseAll(releases)
691- recent := d.sessions[domain.SessionID("recent")]
692- older := d.sessions[domain.SessionID("older")]
693-
694- runPaletteCommand(t, d, current, ac, "BSK")
695- runPaletteCommand(t, d, recent, ac, "BSK")
696- require.Same(t, older, ac.currentSession())
697-
698- d.mu.Lock()
699- delete(d.sessions, recent.id)
700- d.mu.Unlock()
701-
702- runPaletteCommand(t, d, older, ac, "FSK")
703- require.Same(t, current, ac.currentSession())
704- })
705-
706- t.Run("back from current refreshes newly recent sessions", func(t *testing.T) {
707- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
708- defer releaseAll(releases)
709- p, release := newBlockingPTY(t)
710- defer release()
711- newer := &session{id: "newer", name: "newer", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p, domain.Size{Cols: 80, Rows: 23})}}
712- newer.mruAt.Store(25)
713- d.mu.Lock()
714- d.sessions[newer.id] = newer
715- d.mu.Unlock()
716-
717- runPaletteCommand(t, d, current, ac, "BSK")
718- require.Same(t, newer, ac.currentSession())
719- })
720-
721- t.Run("failed switch does not advance navigator index", func(t *testing.T) {
722- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
723- defer releaseAll(releases)
724- current.mu.Lock()
725- current.client = nil
726- current.mu.Unlock()
727-
728- runPaletteCommand(t, d, current, ac, "BSK")
729-
730- require.Same(t, current, ac.currentSession())
731- require.Zero(t, ac.recentNav.index)
732- })
733-}
734-
735 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {452 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
736 t.Helper()453 t.Helper()
737 p1, release1 := newBlockingPTY(t)454 p1, release1 := newBlockingPTY(t)
M internal/usecase/daemon/palette.go +1 -13
@@ -141,9 +141,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
141141 ac.overlays.paletteMu.Unlock()
142142
143143 if run && ok {
144- if !isHistoryNavigationCommand(cmd.Slug) {
145- d.clearHistoryNav(ac)
146- }
147144 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
148145 sess.mu.Lock()
149146 name := sess.name
@@ -159,10 +156,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
159156 }
160157 }
161158
162-func isHistoryNavigationCommand(slug string) bool {
163- return slug == "back-session" || slug == "forward-session"
164-}
165-
166159 type paletteExec struct {
167160 d *Daemon
168161 sess *session
@@ -253,12 +246,7 @@ func (e paletteExec) ToggleFloatingPane() error {
253246 }
254247
255248 func (e paletteExec) BackSession() error {
256- e.d.navigateRecentSession(e.sess, e.ac, 1)
257- return nil
258-}
259-
260-func (e paletteExec) ForwardSession() error {
261- e.d.navigateRecentSession(e.sess, e.ac, -1)
249+ e.d.backSession(e.sess, e.ac)
262250 return nil
263251 }
264252
@@ -141,9 +141,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
141 ac.overlays.paletteMu.Unlock()141 ac.overlays.paletteMu.Unlock()
142 142
143 if run && ok {143 if run && ok {
144- if !isHistoryNavigationCommand(cmd.Slug) {
145- d.clearHistoryNav(ac)
146- }
147 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {144 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
148 sess.mu.Lock()145 sess.mu.Lock()
149 name := sess.name146 name := sess.name
@@ -159,10 +156,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
159 }156 }
160 }157 }
161 158
162-func isHistoryNavigationCommand(slug string) bool {
163- return slug == "back-session" || slug == "forward-session"
164-}
165-
166 type paletteExec struct {159 type paletteExec struct {
167 d *Daemon160 d *Daemon
168 sess *session161 sess *session
@@ -253,12 +246,7 @@ func (e paletteExec) ToggleFloatingPane() error {
253 }246 }
254 247
255 func (e paletteExec) BackSession() error {248 func (e paletteExec) BackSession() error {
256- e.d.navigateRecentSession(e.sess, e.ac, 1)249+ e.d.backSession(e.sess, e.ac)
257- return nil
258-}
259-
260-func (e paletteExec) ForwardSession() error {
261- e.d.navigateRecentSession(e.sess, e.ac, -1)
262 return nil250 return nil
263 }251 }
264 252
M internal/usecase/daemon/palette_test.go +2 -2
@@ -138,12 +138,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
138138 for i, cmd := range commands {
139139 codes[i] = cmd.Code
140140 }
141- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
141+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "VIS", "RNS", "RNT", "DET"}, codes)
142142 }
143143
144144 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
145145 d := &Daemon{}
146- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
146+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
147147
148148 var wg sync.WaitGroup
149149 for range 50 {
@@ -138,12 +138,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
138 for i, cmd := range commands {138 for i, cmd := range commands {
139 codes[i] = cmd.Code139 codes[i] = cmd.Code
140 }140 }
141- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)141+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "VIS", "RNS", "RNT", "DET"}, codes)
142 }142 }
143 143
144 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {144 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
145 d := &Daemon{}145 d := &Daemon{}
146- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}146+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
147 147
148 var wg sync.WaitGroup148 var wg sync.WaitGroup
149 for range 50 {149 for range 50 {
M internal/usecase/daemon/picker.go +6 -1
@@ -354,7 +354,6 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
354354 }
355355
356356 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
357- d.clearHistoryNav(ac)
358357 if target.Stopped {
359358 d.resumeStoppedAndSwitch(sess, ac, target)
360359 return
@@ -377,7 +376,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
377376 return
378377 }
379378 if old != nil && old != ac {
379+
380380 d.unregisterPreview(old)
381+ old.clearPreviousSession()
381382 old.setSession(nil)
382383 d.notifyDetachedAsync(old, ports.ReasonDetach)
383384 }
@@ -409,8 +410,10 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
409410 targetSess.active = target.TabIndex
410411 }
411412 targetSess.mu.Unlock()
413+
412414 d.touchMRU(targetSess)
413415 ac.setSession(targetSess)
416+ ac.recordPreviousSession(from)
414417 d.mu.Unlock()
415418 return old
416419 }
@@ -445,8 +448,10 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
445448 targetSess.mu.Lock()
446449 targetSess.client = ac
447450 targetSess.mu.Unlock()
451+
448452 d.touchMRU(targetSess)
449453 ac.setSession(targetSess)
454+ ac.recordPreviousSession(from)
450455 d.mu.Unlock()
451456 d.firstPaint(targetSess, ac, ac.size)
452457 }
@@ -354,7 +354,6 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
354 }354 }
355 355
356 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {356 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
357- d.clearHistoryNav(ac)
358 if target.Stopped {357 if target.Stopped {
359 d.resumeStoppedAndSwitch(sess, ac, target)358 d.resumeStoppedAndSwitch(sess, ac, target)
360 return359 return
@@ -377,7 +376,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
377 return376 return
378 }377 }
379 if old != nil && old != ac {378 if old != nil && old != ac {
379+
380 d.unregisterPreview(old)380 d.unregisterPreview(old)
381+ old.clearPreviousSession()
381 old.setSession(nil)382 old.setSession(nil)
382 d.notifyDetachedAsync(old, ports.ReasonDetach)383 d.notifyDetachedAsync(old, ports.ReasonDetach)
383 }384 }
@@ -409,8 +410,10 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
409 targetSess.active = target.TabIndex410 targetSess.active = target.TabIndex
410 }411 }
411 targetSess.mu.Unlock()412 targetSess.mu.Unlock()
413+
412 d.touchMRU(targetSess)414 d.touchMRU(targetSess)
413 ac.setSession(targetSess)415 ac.setSession(targetSess)
416+ ac.recordPreviousSession(from)
414 d.mu.Unlock()417 d.mu.Unlock()
415 return old418 return old
416 }419 }
@@ -445,8 +448,10 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
445 targetSess.mu.Lock()448 targetSess.mu.Lock()
446 targetSess.client = ac449 targetSess.client = ac
447 targetSess.mu.Unlock()450 targetSess.mu.Unlock()
451+
448 d.touchMRU(targetSess)452 d.touchMRU(targetSess)
449 ac.setSession(targetSess)453 ac.setSession(targetSess)
454+ ac.recordPreviousSession(from)
450 d.mu.Unlock()455 d.mu.Unlock()
451 d.firstPaint(targetSess, ac, ac.size)456 d.firstPaint(targetSess, ac, ac.size)
452 }457 }
D internal/usecase/daemon/recent_nav.go +0 -163
@@ -1,163 +0,0 @@
1-package daemon
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8- "github.com/bnema/vev/internal/usecase/picker"
9-)
10-
11-const historyNavDisplayTimeout = 1500 * time.Millisecond
12-
13-type recentSessionNavigator struct {
14- ids []domain.SessionID
15- index int
16-}
17-
18-type historyNavDisplay struct {
19- ids []domain.SessionID
20- index int
21- gen uint64
22-}
23-
24-func (h *historyNavDisplay) set(ids []domain.SessionID, index int, gen uint64) {
25- h.ids = append(h.ids[:0], ids...)
26- h.index = index
27- h.gen = gen
28-}
29-
30-func (h *historyNavDisplay) clear() {
31- h.ids = h.ids[:0]
32- h.index = 0
33-}
34-
35-func (h historyNavDisplay) active() bool {
36- return len(h.ids) > 0 && h.index >= 0 && h.index < len(h.ids)
37-}
38-
39-func (n recentSessionNavigator) activeForDisplay() bool {
40- return len(n.ids) > 1 && n.index >= 0 && n.index < len(n.ids)
41-}
42-
43-func recentSessionTrail(cur *session, state barState) []domain.SessionID {
44- if cur == nil {
45- return nil
46- }
47- ids := make([]domain.SessionID, 0, 1+len(state.mru))
48- ids = append(ids, cur.id)
49- for _, entry := range state.mru {
50- if entry.id != "" {
51- ids = append(ids, entry.id)
52- }
53- }
54- return ids
55-}
56-
57-func (n *recentSessionNavigator) reset(ids []domain.SessionID) {
58- n.ids = append(n.ids[:0], ids...)
59- n.index = 0
60-}
61-
62-func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta int) {
63- if d == nil || sess == nil || ac == nil || delta == 0 {
64- return
65- }
66- trail := recentSessionTrail(sess, d.barStateFor(sess, ""))
67- if ac.recentNav.index == 0 && !sameSessionTrail(ac.recentNav.ids, trail) {
68- ac.recentNav.reset(trail)
69- }
70- if !d.pruneRecentNav(&ac.recentNav) || ac.recentNav.ids[ac.recentNav.index] != sess.id {
71- ac.recentNav.reset(trail)
72- }
73-
74- next := ac.recentNav.index + delta
75- if next < 0 || next >= len(ac.recentNav.ids) {
76- d.paint(sess, ac, true)
77- return
78- }
79- targetID := ac.recentNav.ids[next]
80- targetSess := d.sessionByID(targetID)
81- if targetSess == nil {
82- d.clearHistoryNav(ac)
83- d.paint(sess, ac, true)
84- return
85- }
86- d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
87- if ac.currentSession() == targetSess {
88- ac.recentNav.index = next
89- d.showHistoryNav(ac)
90- d.paint(targetSess, ac, true)
91- }
92-}
93-
94-func (d *Daemon) showHistoryNav(ac *attachedClient) {
95- if d == nil || ac == nil || !ac.recentNav.activeForDisplay() {
96- return
97- }
98- ac.historyNavMu.Lock()
99- ac.historyNav.set(ac.recentNav.ids, ac.recentNav.index, ac.historyNav.gen+1)
100- gen := ac.historyNav.gen
101- ac.historyNavTimer.stop()
102- ac.historyNavTimer.retain(d.clock, historyNavDisplayTimeout, func(ports.Timer) {
103- ac.historyNavMu.Lock()
104- if ac.historyNav.gen != gen {
105- ac.historyNavMu.Unlock()
106- return
107- }
108- ac.historyNav.clear()
109- ac.historyNavMu.Unlock()
110- if sess := ac.currentSession(); sess != nil {
111- sess.mu.Lock()
112- stillCurrent := sess.client == ac
113- sess.mu.Unlock()
114- if stillCurrent {
115- d.paint(sess, ac, true)
116- }
117- }
118- })
119- ac.historyNavMu.Unlock()
120-}
121-
122-func (d *Daemon) clearHistoryNav(ac *attachedClient) {
123- if ac == nil {
124- return
125- }
126- ac.clearHistoryNav()
127-}
128-
129-func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
130- if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {
131- return false
132- }
133- current := n.ids[n.index]
134- kept := n.ids[:0]
135- nextIndex := -1
136- for _, id := range n.ids {
137- if d.sessionByID(id) == nil {
138- continue
139- }
140- if id == current {
141- nextIndex = len(kept)
142- }
143- kept = append(kept, id)
144- }
145- n.ids = kept
146- if nextIndex == -1 {
147- return false
148- }
149- n.index = nextIndex
150- return true
151-}
152-
153-func sameSessionTrail(a, b []domain.SessionID) bool {
154- if len(a) != len(b) {
155- return false
156- }
157- for i := range a {
158- if a[i] != b[i] {
159- return false
160- }
161- }
162- return true
163-}
@@ -1,163 +0,0 @@
1-package daemon
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8- "github.com/bnema/vev/internal/usecase/picker"
9-)
10-
11-const historyNavDisplayTimeout = 1500 * time.Millisecond
12-
13-type recentSessionNavigator struct {
14- ids []domain.SessionID
15- index int
16-}
17-
18-type historyNavDisplay struct {
19- ids []domain.SessionID
20- index int
21- gen uint64
22-}
23-
24-func (h *historyNavDisplay) set(ids []domain.SessionID, index int, gen uint64) {
25- h.ids = append(h.ids[:0], ids...)
26- h.index = index
27- h.gen = gen
28-}
29-
30-func (h *historyNavDisplay) clear() {
31- h.ids = h.ids[:0]
32- h.index = 0
33-}
34-
35-func (h historyNavDisplay) active() bool {
36- return len(h.ids) > 0 && h.index >= 0 && h.index < len(h.ids)
37-}
38-
39-func (n recentSessionNavigator) activeForDisplay() bool {
40- return len(n.ids) > 1 && n.index >= 0 && n.index < len(n.ids)
41-}
42-
43-func recentSessionTrail(cur *session, state barState) []domain.SessionID {
44- if cur == nil {
45- return nil
46- }
47- ids := make([]domain.SessionID, 0, 1+len(state.mru))
48- ids = append(ids, cur.id)
49- for _, entry := range state.mru {
50- if entry.id != "" {
51- ids = append(ids, entry.id)
52- }
53- }
54- return ids
55-}
56-
57-func (n *recentSessionNavigator) reset(ids []domain.SessionID) {
58- n.ids = append(n.ids[:0], ids...)
59- n.index = 0
60-}
61-
62-func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta int) {
63- if d == nil || sess == nil || ac == nil || delta == 0 {
64- return
65- }
66- trail := recentSessionTrail(sess, d.barStateFor(sess, ""))
67- if ac.recentNav.index == 0 && !sameSessionTrail(ac.recentNav.ids, trail) {
68- ac.recentNav.reset(trail)
69- }
70- if !d.pruneRecentNav(&ac.recentNav) || ac.recentNav.ids[ac.recentNav.index] != sess.id {
71- ac.recentNav.reset(trail)
72- }
73-
74- next := ac.recentNav.index + delta
75- if next < 0 || next >= len(ac.recentNav.ids) {
76- d.paint(sess, ac, true)
77- return
78- }
79- targetID := ac.recentNav.ids[next]
80- targetSess := d.sessionByID(targetID)
81- if targetSess == nil {
82- d.clearHistoryNav(ac)
83- d.paint(sess, ac, true)
84- return
85- }
86- d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
87- if ac.currentSession() == targetSess {
88- ac.recentNav.index = next
89- d.showHistoryNav(ac)
90- d.paint(targetSess, ac, true)
91- }
92-}
93-
94-func (d *Daemon) showHistoryNav(ac *attachedClient) {
95- if d == nil || ac == nil || !ac.recentNav.activeForDisplay() {
96- return
97- }
98- ac.historyNavMu.Lock()
99- ac.historyNav.set(ac.recentNav.ids, ac.recentNav.index, ac.historyNav.gen+1)
100- gen := ac.historyNav.gen
101- ac.historyNavTimer.stop()
102- ac.historyNavTimer.retain(d.clock, historyNavDisplayTimeout, func(ports.Timer) {
103- ac.historyNavMu.Lock()
104- if ac.historyNav.gen != gen {
105- ac.historyNavMu.Unlock()
106- return
107- }
108- ac.historyNav.clear()
109- ac.historyNavMu.Unlock()
110- if sess := ac.currentSession(); sess != nil {
111- sess.mu.Lock()
112- stillCurrent := sess.client == ac
113- sess.mu.Unlock()
114- if stillCurrent {
115- d.paint(sess, ac, true)
116- }
117- }
118- })
119- ac.historyNavMu.Unlock()
120-}
121-
122-func (d *Daemon) clearHistoryNav(ac *attachedClient) {
123- if ac == nil {
124- return
125- }
126- ac.clearHistoryNav()
127-}
128-
129-func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
130- if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {
131- return false
132- }
133- current := n.ids[n.index]
134- kept := n.ids[:0]
135- nextIndex := -1
136- for _, id := range n.ids {
137- if d.sessionByID(id) == nil {
138- continue
139- }
140- if id == current {
141- nextIndex = len(kept)
142- }
143- kept = append(kept, id)
144- }
145- n.ids = kept
146- if nextIndex == -1 {
147- return false
148- }
149- n.index = nextIndex
150- return true
151-}
152-
153-func sameSessionTrail(a, b []domain.SessionID) bool {
154- if len(a) != len(b) {
155- return false
156- }
157- for i := range a {
158- if a[i] != b[i] {
159- return false
160- }
161- }
162- return true
163-}
M internal/usecase/daemon/render_test.go +0 -65
@@ -1348,71 +1348,6 @@ func cursorCSI(row, col int) string {
13481348 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
13491349 }
13501350
1351-func TestHistoryNavBottomBar(t *testing.T) {
1352- styles := newThemeStyles(themeui.Theme{})
1353-
1354- t.Run("frozen order active styling and right text", func(t *testing.T) {
1355- row := make([]renderer.Cell, 40)
1356- state := barState{
1357- bottomRight: "script",
1358- history: []historyNavSession{
1359- {id: "work", name: "work"},
1360- {id: "api", name: "api", active: true},
1361- {id: "docs", name: "docs", ephemeral: true},
1362- },
1363- }
1364-
1365- drawStatusBarState(row, state, styles)
1366-
1367- got := cellsString(row)
1368- require.Contains(t, got, " work api docs* ")
1369- require.Contains(t, got, " script")
1370- apiCol := strings.Index(got, "api")
1371- require.NotEqual(t, -1, apiCol)
1372- require.True(t, row[apiCol].Style.Equal(styles.accent))
1373- workCol := strings.Index(got, "work")
1374- require.NotEqual(t, -1, workCol)
1375- require.True(t, row[workCol].Style.Equal(styles.statusBar))
1376- })
1377-
1378- t.Run("empty history falls back to normal mru", func(t *testing.T) {
1379- row := make([]renderer.Cell, 30)
1380- state := barState{
1381- status: statusSnapshot{session: "work"},
1382- mru: []mruSession{{name: "api"}, {name: "docs"}},
1383- }
1384-
1385- drawStatusBarState(row, state, styles)
1386-
1387- got := cellsString(row)
1388- require.Contains(t, got, " work api docs ")
1389- workCol := strings.Index(got, "work")
1390- require.NotEqual(t, -1, workCol)
1391- require.True(t, row[workCol].Style.Equal(styles.accent))
1392- })
1393-
1394- t.Run("narrow fitting keeps active visible in contiguous window", func(t *testing.T) {
1395- row := make([]renderer.Cell, 13)
1396- state := barState{history: []historyNavSession{
1397- {name: "work"},
1398- {name: "api"},
1399- {name: "docs", active: true},
1400- {name: "infra"},
1401- {name: "ops"},
1402- }}
1403-
1404- drawStatusBarState(row, state, styles)
1405-
1406- got := cellsString(row)
1407- require.Contains(t, got, "docs")
1408- require.NotContains(t, got, "work")
1409- require.NotContains(t, got, "ops")
1410- docsCol := strings.Index(got, "docs")
1411- require.NotEqual(t, -1, docsCol)
1412- require.True(t, row[docsCol].Style.Equal(styles.accent))
1413- })
1414-}
1415-
14161351 func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
14171352 base := renderer.NewFrame(20, 8)
14181353 for y := range base.Height {
@@ -1348,71 +1348,6 @@ func cursorCSI(row, col int) string {
1348 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"1348 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
1349 }1349 }
1350 1350
1351-func TestHistoryNavBottomBar(t *testing.T) {
1352- styles := newThemeStyles(themeui.Theme{})
1353-
1354- t.Run("frozen order active styling and right text", func(t *testing.T) {
1355- row := make([]renderer.Cell, 40)
1356- state := barState{
1357- bottomRight: "script",
1358- history: []historyNavSession{
1359- {id: "work", name: "work"},
1360- {id: "api", name: "api", active: true},
1361- {id: "docs", name: "docs", ephemeral: true},
1362- },
1363- }
1364-
1365- drawStatusBarState(row, state, styles)
1366-
1367- got := cellsString(row)
1368- require.Contains(t, got, " work api docs* ")
1369- require.Contains(t, got, " script")
1370- apiCol := strings.Index(got, "api")
1371- require.NotEqual(t, -1, apiCol)
1372- require.True(t, row[apiCol].Style.Equal(styles.accent))
1373- workCol := strings.Index(got, "work")
1374- require.NotEqual(t, -1, workCol)
1375- require.True(t, row[workCol].Style.Equal(styles.statusBar))
1376- })
1377-
1378- t.Run("empty history falls back to normal mru", func(t *testing.T) {
1379- row := make([]renderer.Cell, 30)
1380- state := barState{
1381- status: statusSnapshot{session: "work"},
1382- mru: []mruSession{{name: "api"}, {name: "docs"}},
1383- }
1384-
1385- drawStatusBarState(row, state, styles)
1386-
1387- got := cellsString(row)
1388- require.Contains(t, got, " work api docs ")
1389- workCol := strings.Index(got, "work")
1390- require.NotEqual(t, -1, workCol)
1391- require.True(t, row[workCol].Style.Equal(styles.accent))
1392- })
1393-
1394- t.Run("narrow fitting keeps active visible in contiguous window", func(t *testing.T) {
1395- row := make([]renderer.Cell, 13)
1396- state := barState{history: []historyNavSession{
1397- {name: "work"},
1398- {name: "api"},
1399- {name: "docs", active: true},
1400- {name: "infra"},
1401- {name: "ops"},
1402- }}
1403-
1404- drawStatusBarState(row, state, styles)
1405-
1406- got := cellsString(row)
1407- require.Contains(t, got, "docs")
1408- require.NotContains(t, got, "work")
1409- require.NotContains(t, got, "ops")
1410- docsCol := strings.Index(got, "docs")
1411- require.NotEqual(t, -1, docsCol)
1412- require.True(t, row[docsCol].Style.Equal(styles.accent))
1413- })
1414-}
1415-
1416 func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {1351 func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1417 base := renderer.NewFrame(20, 8)1352 base := renderer.NewFrame(20, 8)
1418 for y := range base.Height {1353 for y := range base.Height {
M internal/usecase/daemon/session.go +2 -0
@@ -261,8 +261,10 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
261261 newSess.mu.Lock()
262262 newSess.client = ac
263263 newSess.mu.Unlock()
264+
264265 d.touchMRU(newSess)
265266 ac.setSession(newSess)
267+ ac.recordPreviousSession(from)
266268 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
267269 d.mu.Unlock()
268270
@@ -261,8 +261,10 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
261 newSess.mu.Lock()261 newSess.mu.Lock()
262 newSess.client = ac262 newSess.client = ac
263 newSess.mu.Unlock()263 newSess.mu.Unlock()
264+
264 d.touchMRU(newSess)265 d.touchMRU(newSess)
265 ac.setSession(newSess)266 ac.setSession(newSess)
267+ ac.recordPreviousSession(from)
266 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)268 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
267 d.mu.Unlock()269 d.mu.Unlock()
268 270
A internal/usecase/daemon/session_back.go +26 -0
@@ -0,0 +1,26 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/usecase/picker"
4+
5+// backSession toggles this attachment between its current session and the
6+// immediately preceding successfully activated session.
7+func (d *Daemon) backSession(current *session, ac *attachedClient) {
8+ if d == nil || current == nil || ac == nil {
9+ return
10+ }
11+ target := ac.previousSession.Get()
12+ if target == nil || target == current || d.sessionByID(target.id) != target {
13+ d.paint(current, ac, true)
14+ return
15+ }
16+ d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
17+}
18+
19+// recordPreviousSession is called only after a cross-session hand-off has
20+// committed. Keeping this after the commit makes failed/displaced switches
21+// leave the toggle pair intact.
22+func (ac *attachedClient) recordPreviousSession(origin *session) {
23+ if ac != nil && origin != nil {
24+ ac.previousSession.Set(origin)
25+ }
26+}
@@ -0,0 +1,26 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/usecase/picker"
4+
5+// backSession toggles this attachment between its current session and the
6+// immediately preceding successfully activated session.
7+func (d *Daemon) backSession(current *session, ac *attachedClient) {
8+ if d == nil || current == nil || ac == nil {
9+ return
10+ }
11+ target := ac.previousSession.Get()
12+ if target == nil || target == current || d.sessionByID(target.id) != target {
13+ d.paint(current, ac, true)
14+ return
15+ }
16+ d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
17+}
18+
19+// recordPreviousSession is called only after a cross-session hand-off has
20+// committed. Keeping this after the commit makes failed/displaced switches
21+// leave the toggle pair intact.
22+func (ac *attachedClient) recordPreviousSession(origin *session) {
23+ if ac != nil && origin != nil {
24+ ac.previousSession.Set(origin)
25+ }
26+}
M internal/usecase/daemon/status.go +4 -139
@@ -19,10 +19,8 @@
1919 //
2020 // Locking: a pane's screen/scrollback and per-client renderer shadow are
2121 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. History-navigation display state is
23-// guarded by attachedClient.historyNavMu. When more than one is held the order
24-// is always attachedClient.sendMu > attachedClient.historyNavMu > Daemon.mu >
25-// session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it
2624 // never blocks on a slow client.
2725 package daemon
2826
@@ -84,14 +82,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
8482 clearStatusRow(row)
8583 x := 0
8684 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
87- if len(state.history) > 0 {
88- drawHistoryNavSessions(row, &x, state.history, rightText, state.attentionFrame, styles)
89- drawRightPlainText(row, rightText, x, styles.statusBar)
90- return
91- }
92-
9385 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
94-
9586 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
9687 for i, sess := range fittedMRU {
9788 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
@@ -100,16 +91,6 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
10091 drawRightPlainText(row, rightText, x, styles.statusBar)
10192 }
10293
103-func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSession, rightText string, attentionFrame int, styles themeStyles) {
104- for _, sess := range fitHistoryNav(entries, len(row), *x, rightText) {
105- style := styles.statusBar
106- if sess.active {
107- style = styles.accent
108- }
109- drawStatusSessionEntry(row, x, sess.name, sess.ephemeral, sess.attention, style, attentionFrame)
110- }
111-}
112-
11394 func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) {
11495 if ephemeral {
11596 name += "*"
@@ -161,7 +142,6 @@ type barState struct {
161142 bottomRight string
162143 copyFeedback string
163144 mru []mruSession
164- history []historyNavSession
165145 attentionFrame int
166146 // theme is the client's terminal theme, if reported. Its zero value
167147 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -178,14 +158,6 @@ type mruSession struct {
178158 mruAt uint64
179159 }
180160
181-type historyNavSession struct {
182- id domain.SessionID
183- name string
184- ephemeral bool
185- attention bool
186- active bool
187-}
188-
189161 type statusSnapshot struct {
190162 session string
191163 tabs []statusTab
@@ -222,51 +194,8 @@ func tabDisplayName(tb *tab, index int) string {
222194 return strconv.Itoa(index + 1)
223195 }
224196
225-func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
226- state := d.barStateFor(cur, copyFeedback)
227- state.history = d.historyNavBarState(ac)
228- return state
229-}
230-
231-func (d *Daemon) historyNavBarState(ac *attachedClient) []historyNavSession {
232- if d == nil || ac == nil {
233- return nil
234- }
235- ac.historyNavMu.Lock()
236- if !ac.historyNav.active() {
237- ac.historyNavMu.Unlock()
238- return nil
239- }
240- ids := append([]domain.SessionID(nil), ac.historyNav.ids...)
241- activeIndex := ac.historyNav.index
242- ac.historyNavMu.Unlock()
243-
244- d.mu.Lock()
245- out := make([]historyNavSession, 0, len(ids))
246- valid := true
247- for i, id := range ids {
248- sess := d.sessions[id]
249- if sess == nil {
250- valid = false
251- break
252- }
253- sess.mu.Lock()
254- entry := historyNavSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, active: i == activeIndex}
255- for _, tb := range sess.tabs {
256- if tb.attention {
257- entry.attention = true
258- break
259- }
260- }
261- sess.mu.Unlock()
262- out = append(out, entry)
263- }
264- d.mu.Unlock()
265- if !valid {
266- d.clearHistoryNav(ac)
267- return nil
268- }
269- return out
197+func (d *Daemon) barStateForClient(cur *session, _ *attachedClient, copyFeedback string) barState {
198+ return d.barStateFor(cur, copyFeedback)
270199 }
271200
272201 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
@@ -319,70 +248,6 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
319248 return state
320249 }
321250
322-func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback string) []historyNavSession {
323- copyReserve := 1
324- if feedback != "" {
325- copyReserve = statusTextWidth(feedback) + 2
326- }
327- budget := rowLen - leftUsed - copyReserve
328- if budget <= 0 || len(entries) == 0 {
329- return nil
330- }
331- cost := func(e historyNavSession) int {
332- name := e.name
333- if e.ephemeral {
334- name += "*"
335- }
336- n := 2 + statusTextWidth(name)
337- if e.attention {
338- n += 1 + renderer.RuneWidth(attentionGlyph)
339- }
340- return n
341- }
342- used := 0
343- for _, e := range entries {
344- used += cost(e)
345- }
346- if used <= budget {
347- return entries
348- }
349- active := 0
350- for i, e := range entries {
351- if e.active {
352- active = i
353- break
354- }
355- }
356- if cost(entries[active]) > budget {
357- return nil
358- }
359- start, end := active, active+1
360- used = cost(entries[active])
361- for {
362- expanded := false
363- if start > 0 {
364- c := cost(entries[start-1])
365- if used+c <= budget {
366- start--
367- used += c
368- expanded = true
369- }
370- }
371- if end < len(entries) {
372- c := cost(entries[end])
373- if used+c <= budget {
374- end++
375- used += c
376- expanded = true
377- }
378- }
379- if !expanded {
380- break
381- }
382- }
383- return entries[start:end]
384-}
385-
386251 // fitTabLabels returns, per tab, the text drawn between its surrounding
387252 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
388253 func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
@@ -19,10 +19,8 @@
19 //19 //
20 // Locking: a pane's screen/scrollback and per-client renderer shadow are20 // Locking: a pane's screen/scrollback and per-client renderer shadow are
21 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by21 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. History-navigation display state is22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// guarded by attachedClient.historyNavMu. When more than one is held the order23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it
24-// is always attachedClient.sendMu > attachedClient.historyNavMu > Daemon.mu >
25-// session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it
26 // never blocks on a slow client.24 // never blocks on a slow client.
27 package daemon25 package daemon
28 26
@@ -84,14 +82,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
84 clearStatusRow(row)82 clearStatusRow(row)
85 x := 083 x := 0
86 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)84 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
87- if len(state.history) > 0 {
88- drawHistoryNavSessions(row, &x, state.history, rightText, state.attentionFrame, styles)
89- drawRightPlainText(row, rightText, x, styles.statusBar)
90- return
91- }
92-
93 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)85 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
94-
95 fittedMRU := fitMRU(state.mru, len(row), x, rightText)86 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
96 for i, sess := range fittedMRU {87 for i, sess := range fittedMRU {
97 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))88 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
@@ -100,16 +91,6 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
100 drawRightPlainText(row, rightText, x, styles.statusBar)91 drawRightPlainText(row, rightText, x, styles.statusBar)
101 }92 }
102 93
103-func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSession, rightText string, attentionFrame int, styles themeStyles) {
104- for _, sess := range fitHistoryNav(entries, len(row), *x, rightText) {
105- style := styles.statusBar
106- if sess.active {
107- style = styles.accent
108- }
109- drawStatusSessionEntry(row, x, sess.name, sess.ephemeral, sess.attention, style, attentionFrame)
110- }
111-}
112-
113 func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) {94 func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) {
114 if ephemeral {95 if ephemeral {
115 name += "*"96 name += "*"
@@ -161,7 +142,6 @@ type barState struct {
161 bottomRight string142 bottomRight string
162 copyFeedback string143 copyFeedback string
163 mru []mruSession144 mru []mruSession
164- history []historyNavSession
165 attentionFrame int145 attentionFrame int
166 // theme is the client's terminal theme, if reported. Its zero value146 // theme is the client's terminal theme, if reported. Its zero value
167 // (Theme{}, Known: false) is a valid "no theme" default that resolves to147 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -178,14 +158,6 @@ type mruSession struct {
178 mruAt uint64158 mruAt uint64
179 }159 }
180 160
181-type historyNavSession struct {
182- id domain.SessionID
183- name string
184- ephemeral bool
185- attention bool
186- active bool
187-}
188-
189 type statusSnapshot struct {161 type statusSnapshot struct {
190 session string162 session string
191 tabs []statusTab163 tabs []statusTab
@@ -222,51 +194,8 @@ func tabDisplayName(tb *tab, index int) string {
222 return strconv.Itoa(index + 1)194 return strconv.Itoa(index + 1)
223 }195 }
224 196
225-func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {197+func (d *Daemon) barStateForClient(cur *session, _ *attachedClient, copyFeedback string) barState {
226- state := d.barStateFor(cur, copyFeedback)198+ return d.barStateFor(cur, copyFeedback)
227- state.history = d.historyNavBarState(ac)
228- return state
229-}
230-
231-func (d *Daemon) historyNavBarState(ac *attachedClient) []historyNavSession {
232- if d == nil || ac == nil {
233- return nil
234- }
235- ac.historyNavMu.Lock()
236- if !ac.historyNav.active() {
237- ac.historyNavMu.Unlock()
238- return nil
239- }
240- ids := append([]domain.SessionID(nil), ac.historyNav.ids...)
241- activeIndex := ac.historyNav.index
242- ac.historyNavMu.Unlock()
243-
244- d.mu.Lock()
245- out := make([]historyNavSession, 0, len(ids))
246- valid := true
247- for i, id := range ids {
248- sess := d.sessions[id]
249- if sess == nil {
250- valid = false
251- break
252- }
253- sess.mu.Lock()
254- entry := historyNavSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, active: i == activeIndex}
255- for _, tb := range sess.tabs {
256- if tb.attention {
257- entry.attention = true
258- break
259- }
260- }
261- sess.mu.Unlock()
262- out = append(out, entry)
263- }
264- d.mu.Unlock()
265- if !valid {
266- d.clearHistoryNav(ac)
267- return nil
268- }
269- return out
270 }199 }
271 200
272 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {201 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
@@ -319,70 +248,6 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
319 return state248 return state
320 }249 }
321 250
322-func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback string) []historyNavSession {
323- copyReserve := 1
324- if feedback != "" {
325- copyReserve = statusTextWidth(feedback) + 2
326- }
327- budget := rowLen - leftUsed - copyReserve
328- if budget <= 0 || len(entries) == 0 {
329- return nil
330- }
331- cost := func(e historyNavSession) int {
332- name := e.name
333- if e.ephemeral {
334- name += "*"
335- }
336- n := 2 + statusTextWidth(name)
337- if e.attention {
338- n += 1 + renderer.RuneWidth(attentionGlyph)
339- }
340- return n
341- }
342- used := 0
343- for _, e := range entries {
344- used += cost(e)
345- }
346- if used <= budget {
347- return entries
348- }
349- active := 0
350- for i, e := range entries {
351- if e.active {
352- active = i
353- break
354- }
355- }
356- if cost(entries[active]) > budget {
357- return nil
358- }
359- start, end := active, active+1
360- used = cost(entries[active])
361- for {
362- expanded := false
363- if start > 0 {
364- c := cost(entries[start-1])
365- if used+c <= budget {
366- start--
367- used += c
368- expanded = true
369- }
370- }
371- if end < len(entries) {
372- c := cost(entries[end])
373- if used+c <= budget {
374- end++
375- used += c
376- expanded = true
377- }
378- }
379- if !expanded {
380- break
381- }
382- }
383- return entries[start:end]
384-}
385-
386 // fitTabLabels returns, per tab, the text drawn between its surrounding251 // fitTabLabels returns, per tab, the text drawn between its surrounding
387 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.252 // spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
388 func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {253 func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {

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