feat(session): toggle the previously active session
- commit
- 316c07f32b94413fef6911d9f1a5940c66d8593b
- author
- brice · · 2026-07-11 11:41 UTC
- parent
- e94bd47
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 20 | 20 | NextTab() error | |
| 21 | 21 | PrevTab() error | |
| 22 | 22 | BackSession() error | |
| 23 | - | ForwardSession() error | |
| 24 | 23 | Detach() error | |
| 25 | 24 | EnterVisualMode() error | |
| 26 | 25 | RenameSession() error | |
| @@ -20,7 +20,6 @@ type Context interface { | |||||
| 20 | NextTab() error | 20 | NextTab() error | ||
| 21 | PrevTab() error | 21 | PrevTab() error | ||
| 22 | BackSession() error | 22 | BackSession() error | ||
| 23 | - | ForwardSession() error | |||
| 24 | Detach() error | 23 | Detach() error | ||
| 25 | EnterVisualMode() error | 24 | EnterVisualMode() error | ||
| 26 | RenameSession() error | 25 | 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 | |||
| 519 | 519 | 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 | 522 | // NextTab provides a mock function for the type MockContext | |
| 567 | 523 | func (_mock *MockContext) NextTab() error { | |
| 568 | 524 | ret := _mock.Called() | |
| @@ -519,50 +519,6 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont | |||||
| 519 | return _c | 519 | 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 MockContext | 522 | // 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 { | |||
| 22 | 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 | 23 | {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | |
| 24 | 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() }}, | |
| 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() }}, | |
| 27 | 26 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 28 | 27 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | |
| 29 | 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() }}, | |
| @@ -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 ( | |||
| 7 | 7 | ||
| 8 | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | 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 | 12 | if len(commands) != len(wantCodes) { | |
| 13 | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -86,7 +86,6 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 86 | 86 | {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }}, | |
| 87 | 87 | {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }}, | |
| 88 | 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 | 89 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | |
| 91 | 90 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | |
| 92 | 91 | {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 { | |||
| 59 | 59 | theme themeui.Theme | |
| 60 | 60 | clientTheme themeui.Theme | |
| 61 | 61 | 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] | |
| 68 | 65 | linkMu sync.Mutex | |
| 69 | 66 | sendMu sync.Mutex | |
| 70 | 67 | } | |
| @@ -125,22 +122,12 @@ func (ac *attachedClient) initOverlays() { | |||
| 125 | 122 | ||
| 126 | 123 | func (ac *attachedClient) currentSession() *session { return ac.sess.Get() } | |
| 127 | 124 | ||
| 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) } | |
| 134 | 126 | ||
| 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) | |
| 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 | 133 | func (ac *attachedClient) getTheme() themeui.Theme { | |
| @@ -435,7 +422,9 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) { | |||
| 435 | 422 | return | |
| 436 | 423 | } | |
| 437 | 424 | old.cancelResizePaint() | |
| 425 | + | ||
| 438 | 426 | d.unregisterPreview(old) | |
| 427 | + | old.clearPreviousSession() | |
| 439 | 428 | old.setSession(nil) | |
| 440 | 429 | d.log.Info("client displaced") | |
| 441 | 430 | // 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 | 543 | d.log.Info("client parked", "session", sess.name) | |
| 555 | 544 | return | |
| 556 | 545 | } | |
| 546 | + | ||
| 557 | 547 | d.resetScreenDefaultColors(sess) | |
| 548 | + | ac.clearPreviousSession() | |
| 558 | 549 | if explicit { | |
| 559 | 550 | // Synchronous so the ack is delivered before the transport closes | |
| 560 | 551 | // (the client is actively awaiting it), but deadline-bounded so a | |
| @@ -59,12 +59,9 @@ type attachedClient struct { | |||||
| 59 | theme themeui.Theme | 59 | theme themeui.Theme | ||
| 60 | clientTheme themeui.Theme | 60 | clientTheme themeui.Theme | ||
| 61 | lastCursor cursorOut | 61 | lastCursor cursorOut | ||
| 62 | - | recentNav recentSessionNavigator | 62 | + | // previousSession is guarded independently. It is retained through temporary |
| 63 | - | // historyNavMu protects historyNav and historyNavTimer. When paint needs | 63 | + | // 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.Mutex | 65 | linkMu sync.Mutex | ||
| 69 | sendMu sync.Mutex | 66 | 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 | - | return | 129 | + | 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 | return | 422 | 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 new | 430 | // 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 | return | 544 | 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 closes | 550 | // Synchronous so the ack is delivered before the transport closes | ||
| 560 | // (the client is actively awaiting it), but deadline-bounded so a | 551 | // (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 ( | |||
| 17 | 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 18 | "github.com/bnema/vev/internal/usecase/keys" | |
| 19 | 19 | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| 22 | 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 | 420 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 420 | 421 | defer releaseAll(releases) | |
| 421 | 422 | recent := d.sessions[domain.SessionID("recent")] | |
| 422 | - | older := d.sessions[domain.SessionID("older")] | |
| 423 | 423 | ||
| 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} { | |
| 461 | 427 | 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 | + | } | |
| 551 | 430 | } | |
| 552 | 431 | ||
| 553 | - | func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) { | |
| 432 | + | func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) { | |
| 554 | 433 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 555 | 434 | 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 | + | }) | |
| 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 | 452 | func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) { | |
| 736 | 453 | t.Helper() | |
| 737 | 454 | 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 = clock | 436 | + | name string |
| 558 | - | 437 | + | prepare func() | |
| 559 | - | runPaletteCommand(t, d, current, ac, "BSK") | 438 | + | }{ |
| 560 | - | staleTimer := <-clock.timers | 439 | + | {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.timers | 442 | + | } { |
| 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) { | |||
| 141 | 141 | ac.overlays.paletteMu.Unlock() | |
| 142 | 142 | ||
| 143 | 143 | if run && ok { | |
| 144 | - | if !isHistoryNavigationCommand(cmd.Slug) { | |
| 145 | - | d.clearHistoryNav(ac) | |
| 146 | - | } | |
| 147 | 144 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil { | |
| 148 | 145 | sess.mu.Lock() | |
| 149 | 146 | 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 | 159 | type paletteExec struct { | |
| 167 | 160 | d *Daemon | |
| 168 | 161 | sess *session | |
| @@ -253,12 +246,7 @@ func (e paletteExec) ToggleFloatingPane() error { | |||
| 253 | 246 | } | |
| 254 | 247 | ||
| 255 | 248 | 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) | |
| 262 | 250 | return nil | |
| 263 | 251 | } | |
| 264 | 252 | ||
| @@ -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.name | 146 | 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 *Daemon | 160 | d *Daemon | ||
| 168 | sess *session | 161 | 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 nil | 250 | return nil | ||
| 263 | } | 251 | } | ||
| 264 | 252 | ||||
M internal/usecase/daemon/palette_test.go +2 -2
| @@ -138,12 +138,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 138 | 138 | for i, cmd := range commands { | |
| 139 | 139 | 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 | 144 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 145 | 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 | 148 | var wg sync.WaitGroup | |
| 149 | 149 | 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.Code | 139 | 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.WaitGroup | 148 | 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 { | |||
| 354 | 354 | } | |
| 355 | 355 | ||
| 356 | 356 | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) { | |
| 357 | - | d.clearHistoryNav(ac) | |
| 358 | 357 | if target.Stopped { | |
| 359 | 358 | d.resumeStoppedAndSwitch(sess, ac, target) | |
| 360 | 359 | return | |
| @@ -377,7 +376,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||
| 377 | 376 | return | |
| 378 | 377 | } | |
| 379 | 378 | if old != nil && old != ac { | |
| 379 | + | ||
| 380 | 380 | d.unregisterPreview(old) | |
| 381 | + | old.clearPreviousSession() | |
| 381 | 382 | old.setSession(nil) | |
| 382 | 383 | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 383 | 384 | } | |
| @@ -409,8 +410,10 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 409 | 410 | targetSess.active = target.TabIndex | |
| 410 | 411 | } | |
| 411 | 412 | targetSess.mu.Unlock() | |
| 413 | + | ||
| 412 | 414 | d.touchMRU(targetSess) | |
| 413 | 415 | ac.setSession(targetSess) | |
| 416 | + | ac.recordPreviousSession(from) | |
| 414 | 417 | d.mu.Unlock() | |
| 415 | 418 | return old | |
| 416 | 419 | } | |
| @@ -445,8 +448,10 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 445 | 448 | targetSess.mu.Lock() | |
| 446 | 449 | targetSess.client = ac | |
| 447 | 450 | targetSess.mu.Unlock() | |
| 451 | + | ||
| 448 | 452 | d.touchMRU(targetSess) | |
| 449 | 453 | ac.setSession(targetSess) | |
| 454 | + | ac.recordPreviousSession(from) | |
| 450 | 455 | d.mu.Unlock() | |
| 451 | 456 | d.firstPaint(targetSess, ac, ac.size) | |
| 452 | 457 | } | |
| @@ -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 | return | 359 | return | ||
| @@ -377,7 +376,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||||
| 377 | return | 376 | 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.TabIndex | 410 | 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 old | 418 | 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 = ac | 449 | 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 { | |||
| 1348 | 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 | 1351 | func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |
| 1417 | 1352 | base := renderer.NewFrame(20, 8) | |
| 1418 | 1353 | 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 | |||
| 261 | 261 | newSess.mu.Lock() | |
| 262 | 262 | newSess.client = ac | |
| 263 | 263 | newSess.mu.Unlock() | |
| 264 | + | ||
| 264 | 265 | d.touchMRU(newSess) | |
| 265 | 266 | ac.setSession(newSess) | |
| 267 | + | ac.recordPreviousSession(from) | |
| 266 | 268 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | |
| 267 | 269 | d.mu.Unlock() | |
| 268 | 270 | ||
| @@ -261,8 +261,10 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 261 | newSess.mu.Lock() | 261 | newSess.mu.Lock() | ||
| 262 | newSess.client = ac | 262 | 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 @@ | |||
| 19 | 19 | // | |
| 20 | 20 | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | 21 | // 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 | |
| 26 | 24 | // never blocks on a slow client. | |
| 27 | 25 | package daemon | |
| 28 | 26 | ||
| @@ -84,14 +82,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||
| 84 | 82 | clearStatusRow(row) | |
| 85 | 83 | x := 0 | |
| 86 | 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 | 85 | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | |
| 94 | - | ||
| 95 | 86 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 96 | 87 | for i, sess := range fittedMRU { | |
| 97 | 88 | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | |
| @@ -100,16 +91,6 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||
| 100 | 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 | 94 | func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) { | |
| 114 | 95 | if ephemeral { | |
| 115 | 96 | name += "*" | |
| @@ -161,7 +142,6 @@ type barState struct { | |||
| 161 | 142 | bottomRight string | |
| 162 | 143 | copyFeedback string | |
| 163 | 144 | mru []mruSession | |
| 164 | - | history []historyNavSession | |
| 165 | 145 | attentionFrame int | |
| 166 | 146 | // theme is the client's terminal theme, if reported. Its zero value | |
| 167 | 147 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | |
| @@ -178,14 +158,6 @@ type mruSession struct { | |||
| 178 | 158 | 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 | 161 | type statusSnapshot struct { | |
| 190 | 162 | session string | |
| 191 | 163 | tabs []statusTab | |
| @@ -222,51 +194,8 @@ func tabDisplayName(tb *tab, index int) string { | |||
| 222 | 194 | return strconv.Itoa(index + 1) | |
| 223 | 195 | } | |
| 224 | 196 | ||
| 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) | |
| 270 | 199 | } | |
| 271 | 200 | ||
| 272 | 201 | func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |
| @@ -319,70 +248,6 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||
| 319 | 248 | 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 | 251 | // fitTabLabels returns, per tab, the text drawn between its surrounding | |
| 387 | 252 | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | |
| 388 | 253 | 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 are | 20 | // 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 by | 21 | // 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 | 22 | + | // 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 order | 23 | + | // 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 daemon | 25 | 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 := 0 | 83 | 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 string | 142 | bottomRight string | ||
| 162 | copyFeedback string | 143 | copyFeedback string | ||
| 163 | mru []mruSession | 144 | mru []mruSession | ||
| 164 | - | history []historyNavSession | |||
| 165 | attentionFrame int | 145 | attentionFrame int | ||
| 166 | // theme is the client's terminal theme, if reported. Its zero value | 146 | // theme is the client's terminal theme, if reported. Its zero value | ||
| 167 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | 147 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | ||
| @@ -178,14 +158,6 @@ type mruSession struct { | |||||
| 178 | mruAt uint64 | 158 | 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 string | 162 | session string | ||
| 191 | tabs []statusTab | 163 | 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 state | 248 | 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 surrounding | 251 | // 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 { | ||