detached 097cba3

feat(session): add recent navigation commands

commit
097cba32c619e2c92921a7b64cf270ecdc6502cd
author
brice · · 2026-07-05 13:46 UTC
parent
ca585a7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +271 -4
M internal/usecase/command/command.go +2 -0
@@ -18,6 +18,8 @@ type Context interface {
1818 FocusPaneDown() error
1919 NextTab() error
2020 PrevTab() error
21+ BackSession() error
22+ ForwardSession() error
2123 Detach() error
2224 EnterVisualMode() error
2325 RenameSession() error
@@ -18,6 +18,8 @@ type Context interface {
18 FocusPaneDown() error18 FocusPaneDown() error
19 NextTab() error19 NextTab() error
20 PrevTab() error20 PrevTab() error
21+ BackSession() error
22+ ForwardSession() error
21 Detach() error23 Detach() error
22 EnterVisualMode() error24 EnterVisualMode() error
23 RenameSession() error25 RenameSession() error
M internal/usecase/command/context_mock_test.go +88 -0
@@ -35,6 +35,50 @@ func (_m *MockContext) EXPECT() *MockContext_Expecter {
3535 return &MockContext_Expecter{mock: &_m.Mock}
3636 }
3737
38+// BackSession provides a mock function for the type MockContext
39+func (_mock *MockContext) BackSession() error {
40+ ret := _mock.Called()
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for BackSession")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func() error); ok {
48+ r0 = returnFunc()
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockContext_BackSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BackSession'
56+type MockContext_BackSession_Call struct {
57+ *mock.Call
58+}
59+
60+// BackSession is a helper method to define mock.On call
61+func (_e *MockContext_Expecter) BackSession() *MockContext_BackSession_Call {
62+ return &MockContext_BackSession_Call{Call: _e.mock.On("BackSession")}
63+}
64+
65+func (_c *MockContext_BackSession_Call) Run(run func()) *MockContext_BackSession_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ run()
68+ })
69+ return _c
70+}
71+
72+func (_c *MockContext_BackSession_Call) Return(err error) *MockContext_BackSession_Call {
73+ _c.Call.Return(err)
74+ return _c
75+}
76+
77+func (_c *MockContext_BackSession_Call) RunAndReturn(run func() error) *MockContext_BackSession_Call {
78+ _c.Call.Return(run)
79+ return _c
80+}
81+
3882 // ClosePane provides a mock function for the type MockContext
3983 func (_mock *MockContext) ClosePane() error {
4084 ret := _mock.Called()
@@ -475,6 +519,50 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
475519 return _c
476520 }
477521
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+
478566 // NextTab provides a mock function for the type MockContext
479567 func (_mock *MockContext) NextTab() error {
480568 ret := _mock.Called()
@@ -35,6 +35,50 @@ func (_m *MockContext) EXPECT() *MockContext_Expecter {
35 return &MockContext_Expecter{mock: &_m.Mock}35 return &MockContext_Expecter{mock: &_m.Mock}
36 }36 }
37 37
38+// BackSession provides a mock function for the type MockContext
39+func (_mock *MockContext) BackSession() error {
40+ ret := _mock.Called()
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for BackSession")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func() error); ok {
48+ r0 = returnFunc()
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockContext_BackSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BackSession'
56+type MockContext_BackSession_Call struct {
57+ *mock.Call
58+}
59+
60+// BackSession is a helper method to define mock.On call
61+func (_e *MockContext_Expecter) BackSession() *MockContext_BackSession_Call {
62+ return &MockContext_BackSession_Call{Call: _e.mock.On("BackSession")}
63+}
64+
65+func (_c *MockContext_BackSession_Call) Run(run func()) *MockContext_BackSession_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ run()
68+ })
69+ return _c
70+}
71+
72+func (_c *MockContext_BackSession_Call) Return(err error) *MockContext_BackSession_Call {
73+ _c.Call.Return(err)
74+ return _c
75+}
76+
77+func (_c *MockContext_BackSession_Call) RunAndReturn(run func() error) *MockContext_BackSession_Call {
78+ _c.Call.Return(run)
79+ return _c
80+}
81+
38 // ClosePane provides a mock function for the type MockContext82 // ClosePane provides a mock function for the type MockContext
39 func (_mock *MockContext) ClosePane() error {83 func (_mock *MockContext) ClosePane() error {
40 ret := _mock.Called()84 ret := _mock.Called()
@@ -475,6 +519,50 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
475 return _c519 return _c
476 }520 }
477 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+
478 // NextTab provides a mock function for the type MockContext566 // NextTab provides a mock function for the type MockContext
479 func (_mock *MockContext) NextTab() error {567 func (_mock *MockContext) NextTab() error {
480 ret := _mock.Called()568 ret := _mock.Called()
M internal/usecase/command/registry.go +2 -0
@@ -21,6 +21,8 @@ func Registry() []Command {
2121 {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
2222 {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
2323 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24+ {Code: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},
25+ {Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},
2426 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
2527 {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
2628 {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
@@ -21,6 +21,8 @@ func Registry() []Command {
21 {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},21 {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
22 {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},22 {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
23 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},23 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24+ {Code: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},
25+ {Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},
24 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},26 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
25 {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},27 {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
26 {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},28 {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 +3 -1
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -66,6 +66,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
6666 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
6767 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
6868 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
69+ {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
70+ {code: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().Return(nil).Once() }},
6971 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
7072 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
7173 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
@@ -7,7 +7,7 @@ import (
7 7
8 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {8 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
9 commands := Registry()9 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "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))
@@ -66,6 +66,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
66 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},66 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
67 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},67 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
68 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},68 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
69+ {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
70+ {code: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().Return(nil).Once() }},
69 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},71 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
70 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},72 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
71 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},73 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
M internal/usecase/daemon/client.go +1 -0
@@ -56,6 +56,7 @@ type attachedClient struct {
5656 themeMu sync.Mutex
5757 theme themeui.Theme
5858 lastCursor cursorOut
59+ recentNav recentSessionNavigator
5960 linkMu sync.Mutex
6061 sendMu sync.Mutex
6162 }
@@ -56,6 +56,7 @@ type attachedClient struct {
56 themeMu sync.Mutex56 themeMu sync.Mutex
57 theme themeui.Theme57 theme themeui.Theme
58 lastCursor cursorOut58 lastCursor cursorOut
59+ recentNav recentSessionNavigator
59 linkMu sync.Mutex60 linkMu sync.Mutex
60 sendMu sync.Mutex61 sendMu sync.Mutex
61 }62 }
M internal/usecase/daemon/input_test.go +82 -0
@@ -236,6 +236,88 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
236236 }
237237 }
238238
239+func TestPaletteBackForwardSession(t *testing.T) {
240+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
241+ defer releaseAll(releases)
242+ recent := d.sessions[domain.SessionID("recent")]
243+ older := d.sessions[domain.SessionID("older")]
244+
245+ runPaletteCommand(t, d, current, ac, "BSK")
246+ require.Same(t, recent, ac.currentSession())
247+
248+ runPaletteCommand(t, d, recent, ac, "BSK")
249+ require.Same(t, older, ac.currentSession())
250+
251+ runPaletteCommand(t, d, older, ac, "FSK")
252+ require.Same(t, recent, ac.currentSession())
253+
254+ runPaletteCommand(t, d, recent, ac, "FSK")
255+ require.Same(t, current, ac.currentSession())
256+}
257+
258+func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
259+ t.Run("forward from current does not wrap", func(t *testing.T) {
260+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
261+ defer releaseAll(releases)
262+
263+ runPaletteCommand(t, d, current, ac, "FSK")
264+ require.Same(t, current, ac.currentSession())
265+ })
266+
267+ t.Run("back with no MRU does not move", func(t *testing.T) {
268+ p, release := newBlockingPTY(t)
269+ defer release()
270+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
271+
272+ runPaletteCommand(t, d, sess, ac, "BSK")
273+ require.Same(t, sess, ac.currentSession())
274+ })
275+
276+ t.Run("back at oldest does not wrap", func(t *testing.T) {
277+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
278+ defer releaseAll(releases)
279+ older := d.sessions[domain.SessionID("older")]
280+
281+ runPaletteCommand(t, d, current, ac, "BSK")
282+ runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
283+ require.Same(t, older, ac.currentSession())
284+
285+ runPaletteCommand(t, d, older, ac, "BSK")
286+ require.Same(t, older, ac.currentSession())
287+ })
288+}
289+
290+func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
291+ t.Helper()
292+ p1, release1 := newBlockingPTY(t)
293+ p2, release2 := newBlockingPTY(t)
294+ p3, release3 := newBlockingPTY(t)
295+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
296+ current.id = "current"
297+ delete(d.sessions, domain.SessionID("manual"))
298+ d.sessions[current.id] = current
299+ recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
300+ older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
301+ d.sessions[recent.id] = recent
302+ d.sessions[older.id] = older
303+ current.mruAt.Store(30)
304+ recent.mruAt.Store(20)
305+ older.mruAt.Store(10)
306+ return d, current, ac, sends, []func(){release1, release2, release3}
307+}
308+
309+func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
310+ t.Helper()
311+ d.handleInput(sess, ac, []byte("\x1b "))
312+ d.handleInput(sess, ac, []byte(code+"\r"))
313+}
314+
315+func releaseAll(releases []func()) {
316+ for _, release := range releases {
317+ release()
318+ }
319+}
320+
239321 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
240322 writes := make(chan []byte, 1)
241323 p1, releasePTY1 := newBlockingPTY(t)
@@ -236,6 +236,88 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
236 }236 }
237 }237 }
238 238
239+func TestPaletteBackForwardSession(t *testing.T) {
240+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
241+ defer releaseAll(releases)
242+ recent := d.sessions[domain.SessionID("recent")]
243+ older := d.sessions[domain.SessionID("older")]
244+
245+ runPaletteCommand(t, d, current, ac, "BSK")
246+ require.Same(t, recent, ac.currentSession())
247+
248+ runPaletteCommand(t, d, recent, ac, "BSK")
249+ require.Same(t, older, ac.currentSession())
250+
251+ runPaletteCommand(t, d, older, ac, "FSK")
252+ require.Same(t, recent, ac.currentSession())
253+
254+ runPaletteCommand(t, d, recent, ac, "FSK")
255+ require.Same(t, current, ac.currentSession())
256+}
257+
258+func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
259+ t.Run("forward from current does not wrap", func(t *testing.T) {
260+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
261+ defer releaseAll(releases)
262+
263+ runPaletteCommand(t, d, current, ac, "FSK")
264+ require.Same(t, current, ac.currentSession())
265+ })
266+
267+ t.Run("back with no MRU does not move", func(t *testing.T) {
268+ p, release := newBlockingPTY(t)
269+ defer release()
270+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
271+
272+ runPaletteCommand(t, d, sess, ac, "BSK")
273+ require.Same(t, sess, ac.currentSession())
274+ })
275+
276+ t.Run("back at oldest does not wrap", func(t *testing.T) {
277+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
278+ defer releaseAll(releases)
279+ older := d.sessions[domain.SessionID("older")]
280+
281+ runPaletteCommand(t, d, current, ac, "BSK")
282+ runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
283+ require.Same(t, older, ac.currentSession())
284+
285+ runPaletteCommand(t, d, older, ac, "BSK")
286+ require.Same(t, older, ac.currentSession())
287+ })
288+}
289+
290+func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
291+ t.Helper()
292+ p1, release1 := newBlockingPTY(t)
293+ p2, release2 := newBlockingPTY(t)
294+ p3, release3 := newBlockingPTY(t)
295+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
296+ current.id = "current"
297+ delete(d.sessions, domain.SessionID("manual"))
298+ d.sessions[current.id] = current
299+ recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
300+ older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
301+ d.sessions[recent.id] = recent
302+ d.sessions[older.id] = older
303+ current.mruAt.Store(30)
304+ recent.mruAt.Store(20)
305+ older.mruAt.Store(10)
306+ return d, current, ac, sends, []func(){release1, release2, release3}
307+}
308+
309+func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
310+ t.Helper()
311+ d.handleInput(sess, ac, []byte("\x1b "))
312+ d.handleInput(sess, ac, []byte(code+"\r"))
313+}
314+
315+func releaseAll(releases []func()) {
316+ for _, release := range releases {
317+ release()
318+ }
319+}
320+
239 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {321 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
240 writes := make(chan []byte, 1)322 writes := make(chan []byte, 1)
241 p1, releasePTY1 := newBlockingPTY(t)323 p1, releasePTY1 := newBlockingPTY(t)
M internal/usecase/daemon/palette.go +10 -0
@@ -215,6 +215,16 @@ func (e paletteExec) PrevTab() error {
215215 return nil
216216 }
217217
218+func (e paletteExec) BackSession() error {
219+ e.d.navigateRecentSession(e.sess, e.ac, 1)
220+ return nil
221+}
222+
223+func (e paletteExec) ForwardSession() error {
224+ e.d.navigateRecentSession(e.sess, e.ac, -1)
225+ return nil
226+}
227+
218228 func (e paletteExec) Detach() error {
219229 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
220230 return nil
@@ -215,6 +215,16 @@ func (e paletteExec) PrevTab() error {
215 return nil215 return nil
216 }216 }
217 217
218+func (e paletteExec) BackSession() error {
219+ e.d.navigateRecentSession(e.sess, e.ac, 1)
220+ return nil
221+}
222+
223+func (e paletteExec) ForwardSession() error {
224+ e.d.navigateRecentSession(e.sess, e.ac, -1)
225+ return nil
226+}
227+
218 func (e paletteExec) Detach() error {228 func (e paletteExec) Detach() error {
219 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)229 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
220 return nil230 return nil
M internal/usecase/daemon/palette_test.go +2 -2
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112112 for i, cmd := range commands {
113113 codes[i] = cmd.Code
114114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "VIS", "RNS", "DET"}, codes)
115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "DET"}, codes)
116116 }
117117
118118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
121121
122122 var wg sync.WaitGroup
123123 for range 50 {
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112 for i, cmd := range commands {112 for i, cmd := range commands {
113 codes[i] = cmd.Code113 codes[i] = cmd.Code
114 }114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "VIS", "RNS", "DET"}, codes)115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "DET"}, codes)
116 }116 }
117 117
118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119 d := &Daemon{}119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
121 121
122 var wg sync.WaitGroup122 var wg sync.WaitGroup
123 for range 50 {123 for range 50 {
A internal/usecase/daemon/recent_nav.go +78 -0
@@ -0,0 +1,78 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
7+
8+type recentSessionNavigator struct {
9+ ids []domain.SessionID
10+ index int
11+}
12+
13+func recentSessionTrail(cur *session, state barState) []domain.SessionID {
14+ if cur == nil {
15+ return nil
16+ }
17+ ids := make([]domain.SessionID, 0, 1+len(state.mru))
18+ ids = append(ids, cur.id)
19+ for _, entry := range state.mru {
20+ if entry.id != "" {
21+ ids = append(ids, entry.id)
22+ }
23+ }
24+ return ids
25+}
26+
27+func (n *recentSessionNavigator) reset(ids []domain.SessionID) {
28+ n.ids = append(n.ids[:0], ids...)
29+ n.index = 0
30+}
31+
32+func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta int) {
33+ if d == nil || sess == nil || ac == nil || delta == 0 {
34+ return
35+ }
36+ trail := recentSessionTrail(sess, d.barStateFor(sess, ""))
37+ if len(trail) == 0 {
38+ d.paint(sess, ac, true)
39+ return
40+ }
41+ if !d.recentNavValid(&ac.recentNav) || ac.recentNav.index < 0 || ac.recentNav.index >= len(ac.recentNav.ids) || ac.recentNav.ids[ac.recentNav.index] != sess.id {
42+ ac.recentNav.reset(trail)
43+ }
44+
45+ next := ac.recentNav.index + delta
46+ if next < 0 || next >= len(ac.recentNav.ids) {
47+ d.paint(sess, ac, true)
48+ return
49+ }
50+ targetID := ac.recentNav.ids[next]
51+ if d.sessionByID(targetID) == nil {
52+ ac.recentNav.reset(trail)
53+ next = ac.recentNav.index + delta
54+ if next < 0 || next >= len(ac.recentNav.ids) {
55+ d.paint(sess, ac, true)
56+ return
57+ }
58+ targetID = ac.recentNav.ids[next]
59+ if d.sessionByID(targetID) == nil {
60+ d.paint(sess, ac, true)
61+ return
62+ }
63+ }
64+ ac.recentNav.index = next
65+ d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
66+}
67+
68+func (d *Daemon) recentNavValid(n *recentSessionNavigator) bool {
69+ if n == nil || len(n.ids) == 0 {
70+ return false
71+ }
72+ for _, id := range n.ids {
73+ if d.sessionByID(id) == nil {
74+ return false
75+ }
76+ }
77+ return true
78+}
@@ -0,0 +1,78 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/picker"
6+)
7+
8+type recentSessionNavigator struct {
9+ ids []domain.SessionID
10+ index int
11+}
12+
13+func recentSessionTrail(cur *session, state barState) []domain.SessionID {
14+ if cur == nil {
15+ return nil
16+ }
17+ ids := make([]domain.SessionID, 0, 1+len(state.mru))
18+ ids = append(ids, cur.id)
19+ for _, entry := range state.mru {
20+ if entry.id != "" {
21+ ids = append(ids, entry.id)
22+ }
23+ }
24+ return ids
25+}
26+
27+func (n *recentSessionNavigator) reset(ids []domain.SessionID) {
28+ n.ids = append(n.ids[:0], ids...)
29+ n.index = 0
30+}
31+
32+func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta int) {
33+ if d == nil || sess == nil || ac == nil || delta == 0 {
34+ return
35+ }
36+ trail := recentSessionTrail(sess, d.barStateFor(sess, ""))
37+ if len(trail) == 0 {
38+ d.paint(sess, ac, true)
39+ return
40+ }
41+ if !d.recentNavValid(&ac.recentNav) || ac.recentNav.index < 0 || ac.recentNav.index >= len(ac.recentNav.ids) || ac.recentNav.ids[ac.recentNav.index] != sess.id {
42+ ac.recentNav.reset(trail)
43+ }
44+
45+ next := ac.recentNav.index + delta
46+ if next < 0 || next >= len(ac.recentNav.ids) {
47+ d.paint(sess, ac, true)
48+ return
49+ }
50+ targetID := ac.recentNav.ids[next]
51+ if d.sessionByID(targetID) == nil {
52+ ac.recentNav.reset(trail)
53+ next = ac.recentNav.index + delta
54+ if next < 0 || next >= len(ac.recentNav.ids) {
55+ d.paint(sess, ac, true)
56+ return
57+ }
58+ targetID = ac.recentNav.ids[next]
59+ if d.sessionByID(targetID) == nil {
60+ d.paint(sess, ac, true)
61+ return
62+ }
63+ }
64+ ac.recentNav.index = next
65+ d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
66+}
67+
68+func (d *Daemon) recentNavValid(n *recentSessionNavigator) bool {
69+ if n == nil || len(n.ids) == 0 {
70+ return false
71+ }
72+ for _, id := range n.ids {
73+ if d.sessionByID(id) == nil {
74+ return false
75+ }
76+ }
77+ return true
78+}
M internal/usecase/daemon/status.go +3 -1
@@ -29,6 +29,7 @@ import (
2929 "strconv"
3030 "time"
3131
32+ "github.com/bnema/vev/internal/domain"
3233 themeui "github.com/bnema/vev/internal/usecase/theme"
3334 "github.com/bnema/vev/pkg/renderer"
3435 )
@@ -122,6 +123,7 @@ type barState struct {
122123 }
123124
124125 type mruSession struct {
126+ id domain.SessionID
125127 name string
126128 ephemeral bool
127129 attention bool
@@ -174,7 +176,7 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
174176 }
175177 at := sess.mruAt.Load()
176178 sess.mu.Lock()
177- entry := mruSession{name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
179+ entry := mruSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
178180 for _, tb := range sess.tabs {
179181 if tb.attention {
180182 entry.attention = true
@@ -29,6 +29,7 @@ import (
29 "strconv"29 "strconv"
30 "time"30 "time"
31 31
32+ "github.com/bnema/vev/internal/domain"
32 themeui "github.com/bnema/vev/internal/usecase/theme"33 themeui "github.com/bnema/vev/internal/usecase/theme"
33 "github.com/bnema/vev/pkg/renderer"34 "github.com/bnema/vev/pkg/renderer"
34 )35 )
@@ -122,6 +123,7 @@ type barState struct {
122 }123 }
123 124
124 type mruSession struct {125 type mruSession struct {
126+ id domain.SessionID
125 name string127 name string
126 ephemeral bool128 ephemeral bool
127 attention bool129 attention bool
@@ -174,7 +176,7 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
174 }176 }
175 at := sess.mruAt.Load()177 at := sess.mruAt.Load()
176 sess.mu.Lock()178 sess.mu.Lock()
177- entry := mruSession{name: sess.name, ephemeral: sess.ephemeral, mruAt: at}179+ entry := mruSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
178 for _, tb := range sess.tabs {180 for _, tb := range sess.tabs {
179 if tb.attention {181 if tb.attention {
180 entry.attention = true182 entry.attention = true

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close