detached 54b2176

refactor(session): share recent session action state

commit
54b21769d6ea9c3a87db62c857ee95a2b3940de7
author
brice · · 2026-07-11 11:59 UTC
parent
b910459
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +365 -88
M internal/usecase/command/command.go +20 -1
@@ -25,10 +25,29 @@ type Context interface {
2525 RenameSession() error
2626 RenameTab() error
2727 OpenSessionPicker() error
28+ JumpRecentSession(args []string) error
2829 }
2930
31+// Arguments declares whether a command accepts palette arguments.
32+type Arguments uint8
33+
34+const (
35+ ArgumentsNone Arguments = iota
36+ ArgumentsRequired
37+)
38+
39+// ContextHint describes optional contextual data needed to execute a command.
40+type ContextHint uint8
41+
42+const (
43+ ContextHintNone ContextHint = iota
44+ ContextHintRecentSessions
45+)
46+
3047 // Command describes an executable command.
3148 type Command struct {
3249 Slug, Code, Name, Desc string
33- Run func(Context) error
50+ Arguments Arguments
51+ ContextHint ContextHint
52+ Run func(Context, []string) error
3453 }
@@ -25,10 +25,29 @@ type Context interface {
25 RenameSession() error25 RenameSession() error
26 RenameTab() error26 RenameTab() error
27 OpenSessionPicker() error27 OpenSessionPicker() error
28+ JumpRecentSession(args []string) error
28 }29 }
29 30
31+// Arguments declares whether a command accepts palette arguments.
32+type Arguments uint8
33+
34+const (
35+ ArgumentsNone Arguments = iota
36+ ArgumentsRequired
37+)
38+
39+// ContextHint describes optional contextual data needed to execute a command.
40+type ContextHint uint8
41+
42+const (
43+ ContextHintNone ContextHint = iota
44+ ContextHintRecentSessions
45+)
46+
30 // Command describes an executable command.47 // Command describes an executable command.
31 type Command struct {48 type Command struct {
32 Slug, Code, Name, Desc string49 Slug, Code, Name, Desc string
33- Run func(Context) error50+ Arguments Arguments
51+ ContextHint ContextHint
52+ Run func(Context, []string) error
34 }53 }
M internal/usecase/command/context_mock_test.go +51 -0
@@ -519,6 +519,57 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
519519 return _c
520520 }
521521
522+// JumpRecentSession provides a mock function for the type MockContext
523+func (_mock *MockContext) JumpRecentSession(args []string) error {
524+ ret := _mock.Called(args)
525+
526+ if len(ret) == 0 {
527+ panic("no return value specified for JumpRecentSession")
528+ }
529+
530+ var r0 error
531+ if returnFunc, ok := ret.Get(0).(func([]string) error); ok {
532+ r0 = returnFunc(args)
533+ } else {
534+ r0 = ret.Error(0)
535+ }
536+ return r0
537+}
538+
539+// MockContext_JumpRecentSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JumpRecentSession'
540+type MockContext_JumpRecentSession_Call struct {
541+ *mock.Call
542+}
543+
544+// JumpRecentSession is a helper method to define mock.On call
545+// - args []string
546+func (_e *MockContext_Expecter) JumpRecentSession(args any) *MockContext_JumpRecentSession_Call {
547+ return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", args)}
548+}
549+
550+func (_c *MockContext_JumpRecentSession_Call) Run(run func(args []string)) *MockContext_JumpRecentSession_Call {
551+ _c.Call.Run(func(args mock.Arguments) {
552+ var arg0 []string
553+ if args[0] != nil {
554+ arg0 = args[0].([]string)
555+ }
556+ run(
557+ arg0,
558+ )
559+ })
560+ return _c
561+}
562+
563+func (_c *MockContext_JumpRecentSession_Call) Return(err error) *MockContext_JumpRecentSession_Call {
564+ _c.Call.Return(err)
565+ return _c
566+}
567+
568+func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(args []string) error) *MockContext_JumpRecentSession_Call {
569+ _c.Call.Return(run)
570+ return _c
571+}
572+
522573 // NextTab provides a mock function for the type MockContext
523574 func (_mock *MockContext) NextTab() error {
524575 ret := _mock.Called()
@@ -519,6 +519,57 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
519 return _c519 return _c
520 }520 }
521 521
522+// JumpRecentSession provides a mock function for the type MockContext
523+func (_mock *MockContext) JumpRecentSession(args []string) error {
524+ ret := _mock.Called(args)
525+
526+ if len(ret) == 0 {
527+ panic("no return value specified for JumpRecentSession")
528+ }
529+
530+ var r0 error
531+ if returnFunc, ok := ret.Get(0).(func([]string) error); ok {
532+ r0 = returnFunc(args)
533+ } else {
534+ r0 = ret.Error(0)
535+ }
536+ return r0
537+}
538+
539+// MockContext_JumpRecentSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JumpRecentSession'
540+type MockContext_JumpRecentSession_Call struct {
541+ *mock.Call
542+}
543+
544+// JumpRecentSession is a helper method to define mock.On call
545+// - args []string
546+func (_e *MockContext_Expecter) JumpRecentSession(args any) *MockContext_JumpRecentSession_Call {
547+ return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", args)}
548+}
549+
550+func (_c *MockContext_JumpRecentSession_Call) Run(run func(args []string)) *MockContext_JumpRecentSession_Call {
551+ _c.Call.Run(func(args mock.Arguments) {
552+ var arg0 []string
553+ if args[0] != nil {
554+ arg0 = args[0].([]string)
555+ }
556+ run(
557+ arg0,
558+ )
559+ })
560+ return _c
561+}
562+
563+func (_c *MockContext_JumpRecentSession_Call) Return(err error) *MockContext_JumpRecentSession_Call {
564+ _c.Call.Return(err)
565+ return _c
566+}
567+
568+func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(args []string) error) *MockContext_JumpRecentSession_Call {
569+ _c.Call.Return(run)
570+ return _c
571+}
572+
522 // NextTab provides a mock function for the type MockContext573 // NextTab provides a mock function for the type MockContext
523 func (_mock *MockContext) NextTab() error {574 func (_mock *MockContext) NextTab() error {
524 ret := _mock.Called()575 ret := _mock.Called()
M internal/usecase/command/registry.go +24 -23
@@ -5,29 +5,30 @@ import "strings"
55 // Registry returns all commands in display order.
66 func Registry() []Command {
77 commands := []Command{
8- {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9- {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
10- {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11- {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},
12- {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},
13- {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},
14- {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15- {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16- {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17- {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context) error { return ctx.ToggleFloatingPane() }},
18- {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
19- {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
20- {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
21- {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},
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() }},
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: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context) error { return ctx.BackSession() }},
26- {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27- {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
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() }},
29- {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
30- {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
8+ {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context, _ []string) error { return ctx.CreateTab() }},
9+ {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context, _ []string) error { return ctx.CreateSession() }},
10+ {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context, _ []string) error { return ctx.CloseTab() }},
11+ {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context, _ []string) error { return ctx.SplitRight() }},
12+ {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context, _ []string) error { return ctx.SplitLeft() }},
13+ {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context, _ []string) error { return ctx.SplitUp() }},
14+ {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context, _ []string) error { return ctx.SplitDown() }},
15+ {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context, _ []string) error { return ctx.StackPane() }},
16+ {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context, _ []string) error { return ctx.ToggleStack() }},
17+ {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }},
18+ {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context, _ []string) error { return ctx.ClosePane() }},
19+ {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }},
20+ {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }},
21+ {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }},
22+ {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }},
23+ {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) error { return ctx.NextTab() }},
24+ {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context, _ []string) error { return ctx.PrevTab() }},
25+ {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context, _ []string) error { return ctx.BackSession() }},
26+ {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error { return ctx.JumpRecentSession(args) }},
27+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
28+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) 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, _ []string) error { return ctx.RenameSession() }},
30+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},
31+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context, _ []string) error { return ctx.Detach() }},
3132 }
3233
3334 return commands
@@ -5,29 +5,30 @@ import "strings"
5 // Registry returns all commands in display order.5 // Registry returns all commands in display order.
6 func Registry() []Command {6 func Registry() []Command {
7 commands := []Command{7 commands := []Command{
8- {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},8+ {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context, _ []string) error { return ctx.CreateTab() }},
9- {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},9+ {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context, _ []string) error { return ctx.CreateSession() }},
10- {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},10+ {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context, _ []string) error { return ctx.CloseTab() }},
11- {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},11+ {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context, _ []string) error { return ctx.SplitRight() }},
12- {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},12+ {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context, _ []string) error { return ctx.SplitLeft() }},
13- {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},13+ {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context, _ []string) error { return ctx.SplitUp() }},
14- {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},14+ {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context, _ []string) error { return ctx.SplitDown() }},
15- {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},15+ {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context, _ []string) error { return ctx.StackPane() }},
16- {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},16+ {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context, _ []string) error { return ctx.ToggleStack() }},
17- {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context) error { return ctx.ToggleFloatingPane() }},17+ {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }},
18- {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},18+ {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context, _ []string) error { return ctx.ClosePane() }},
19- {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},19+ {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }},
20- {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},20+ {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }},
21- {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},21+ {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }},
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, _ []string) 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, _ []string) 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, _ []string) error { return ctx.PrevTab() }},
25- {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", 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, _ []string) error { return ctx.BackSession() }},
26- {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},26+ {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error { return ctx.JumpRecentSession(args) }},
27- {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},27+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
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() }},28+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
29- {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},29+ {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }},
30- {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},30+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},
31+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context, _ []string) error { return ctx.Detach() }},
31 }32 }
32 33
33 return commands34 return commands
M internal/usecase/command/registry_test.go +3 -2
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "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", "JRS", "SSP", "VIS", "RNS", "RNT", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -86,6 +86,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
8686 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
8787 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
8888 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
89+ {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession([]string(nil)).Return(nil).Once() }},
8990 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
9091 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
9192 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
@@ -102,7 +103,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
102103
103104 ctx := NewMockContext(t)
104105 tt.expect(ctx)
105- if err := cmd.Run(ctx); err != nil {
106+ if err := cmd.Run(ctx, nil); err != nil {
106107 t.Fatalf("Run() error = %v, want nil", err)
107108 }
108109 })
@@ -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", "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", "JRS", "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,6 +86,7 @@ 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: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession([]string(nil)).Return(nil).Once() }},
89 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},90 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
90 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},91 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
91 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},92 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
@@ -102,7 +103,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
102 103
103 ctx := NewMockContext(t)104 ctx := NewMockContext(t)
104 tt.expect(ctx)105 tt.expect(ctx)
105- if err := cmd.Run(ctx); err != nil {106+ if err := cmd.Run(ctx, nil); err != nil {
106 t.Fatalf("Run() error = %v, want nil", err)107 t.Fatalf("Run() error = %v, want nil", err)
107 }108 }
108 })109 })
M internal/usecase/daemon/overlay_runtime.go +1 -0
@@ -21,6 +21,7 @@ type overlayRuntime struct {
2121
2222 paletteMu sync.Mutex
2323 palette *palette.Model
24+ paletteRecent []recentSession
2425 palettePending []byte
2526
2627 promptMu sync.Mutex
@@ -21,6 +21,7 @@ type overlayRuntime struct {
21 21
22 paletteMu sync.Mutex22 paletteMu sync.Mutex
23 palette *palette.Model23 palette *palette.Model
24+ paletteRecent []recentSession
24 palettePending []byte25 palettePending []byte
25 26
26 promptMu sync.Mutex27 promptMu sync.Mutex
M internal/usecase/daemon/palette.go +52 -4
@@ -1,10 +1,13 @@
11 package daemon
22
33 import (
4+ "strconv"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/usecase/command"
68 "github.com/bnema/vev/internal/usecase/layout"
79 "github.com/bnema/vev/internal/usecase/palette"
10+ "github.com/bnema/vev/internal/usecase/picker"
811 "github.com/bnema/vev/internal/usecase/ui"
912 "github.com/bnema/vev/pkg/renderer"
1013 )
@@ -33,6 +36,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
3336 d.closePalette(ac)
3437 ac.overlays.paletteMu.Lock()
3538 ac.overlays.palette = palette.New(d.paletteCommands())
39+ ac.overlays.paletteRecent = d.recentSessions(sess)
3640 ac.overlays.palettePending = nil
3741 ac.overlays.paletteMu.Unlock()
3842 d.paint(sess, ac, true)
@@ -41,6 +45,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4145 func (d *Daemon) closePalette(ac *attachedClient) {
4246 ac.overlays.paletteMu.Lock()
4347 ac.overlays.palette = nil
48+ ac.overlays.paletteRecent = nil
4449 ac.overlays.palettePending = nil
4550 ac.overlays.paletteMu.Unlock()
4651 }
@@ -106,6 +111,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
106111 exit := false
107112 run := false
108113 var cmd command.Command
114+ var recent []recentSession
109115 var ok bool
110116
111117 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
@@ -119,7 +125,15 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
119125 },
120126 enter: func() {
121127 cmd, ok = ac.overlays.palette.Selected()
128+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
122129 if ok {
130+ if cmd.Arguments == command.ArgumentsRequired {
131+ action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query())
132+ if !valid || (cmd.Code == "JRS" && !d.validRecentSessionAction(recent, action.Args)) {
133+ ok = false
134+ return
135+ }
136+ }
123137 run = true
124138 exit = true
125139 }
@@ -141,7 +155,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
141155 ac.overlays.paletteMu.Unlock()
142156
143157 if run && ok {
144- if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
158+ action := palette.Action{Command: cmd}
159+ if parsed, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()); valid {
160+ action = parsed
161+ }
162+ if err := action.Command.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, action.Args); err != nil {
145163 sess.mu.Lock()
146164 name := sess.name
147165 sess.mu.Unlock()
@@ -156,10 +174,22 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
156174 }
157175 }
158176
177+func (d *Daemon) validRecentSessionAction(recent []recentSession, args []string) bool {
178+ if len(args) != 1 {
179+ return false
180+ }
181+ rank, err := strconv.Atoi(args[0])
182+ if err != nil || rank < 1 || rank > len(recent) {
183+ return false
184+ }
185+ return d.sessionByID(recent[rank-1].id) != nil
186+}
187+
159188 type paletteExec struct {
160- d *Daemon
161- sess *session
162- ac *attachedClient
189+ d *Daemon
190+ sess *session
191+ ac *attachedClient
192+ recent []recentSession
163193 }
164194
165195 func (e paletteExec) CreateTab() error {
@@ -289,6 +319,24 @@ func (e paletteExec) OpenSessionPicker() error {
289319 return nil
290320 }
291321
322+func (e paletteExec) JumpRecentSession(args []string) error {
323+ if len(args) != 1 {
324+ return nil
325+ }
326+ rank, err := strconv.Atoi(args[0])
327+ if err != nil || rank < 1 || rank > len(e.recent) {
328+ return nil
329+ }
330+ // The snapshot is value-only; resolve the ID at execution so a removed
331+ // target is never resurrected through a retained session pointer.
332+ target := e.recent[rank-1]
333+ if e.d.sessionByID(target.id) == nil {
334+ return nil
335+ }
336+ e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
337+ return nil
338+}
339+
292340 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
293341 styleSet := resolveThemeStyles(styles)
294342 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
@@ -1,10 +1,13 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "strconv"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
6 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
7 "github.com/bnema/vev/internal/usecase/palette"9 "github.com/bnema/vev/internal/usecase/palette"
10+ "github.com/bnema/vev/internal/usecase/picker"
8 "github.com/bnema/vev/internal/usecase/ui"11 "github.com/bnema/vev/internal/usecase/ui"
9 "github.com/bnema/vev/pkg/renderer"12 "github.com/bnema/vev/pkg/renderer"
10 )13 )
@@ -33,6 +36,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
33 d.closePalette(ac)36 d.closePalette(ac)
34 ac.overlays.paletteMu.Lock()37 ac.overlays.paletteMu.Lock()
35 ac.overlays.palette = palette.New(d.paletteCommands())38 ac.overlays.palette = palette.New(d.paletteCommands())
39+ ac.overlays.paletteRecent = d.recentSessions(sess)
36 ac.overlays.palettePending = nil40 ac.overlays.palettePending = nil
37 ac.overlays.paletteMu.Unlock()41 ac.overlays.paletteMu.Unlock()
38 d.paint(sess, ac, true)42 d.paint(sess, ac, true)
@@ -41,6 +45,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
41 func (d *Daemon) closePalette(ac *attachedClient) {45 func (d *Daemon) closePalette(ac *attachedClient) {
42 ac.overlays.paletteMu.Lock()46 ac.overlays.paletteMu.Lock()
43 ac.overlays.palette = nil47 ac.overlays.palette = nil
48+ ac.overlays.paletteRecent = nil
44 ac.overlays.palettePending = nil49 ac.overlays.palettePending = nil
45 ac.overlays.paletteMu.Unlock()50 ac.overlays.paletteMu.Unlock()
46 }51 }
@@ -106,6 +111,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
106 exit := false111 exit := false
107 run := false112 run := false
108 var cmd command.Command113 var cmd command.Command
114+ var recent []recentSession
109 var ok bool115 var ok bool
110 116
111 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{117 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
@@ -119,7 +125,15 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
119 },125 },
120 enter: func() {126 enter: func() {
121 cmd, ok = ac.overlays.palette.Selected()127 cmd, ok = ac.overlays.palette.Selected()
128+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
122 if ok {129 if ok {
130+ if cmd.Arguments == command.ArgumentsRequired {
131+ action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query())
132+ if !valid || (cmd.Code == "JRS" && !d.validRecentSessionAction(recent, action.Args)) {
133+ ok = false
134+ return
135+ }
136+ }
123 run = true137 run = true
124 exit = true138 exit = true
125 }139 }
@@ -141,7 +155,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
141 ac.overlays.paletteMu.Unlock()155 ac.overlays.paletteMu.Unlock()
142 156
143 if run && ok {157 if run && ok {
144- if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {158+ action := palette.Action{Command: cmd}
159+ if parsed, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()); valid {
160+ action = parsed
161+ }
162+ if err := action.Command.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, action.Args); err != nil {
145 sess.mu.Lock()163 sess.mu.Lock()
146 name := sess.name164 name := sess.name
147 sess.mu.Unlock()165 sess.mu.Unlock()
@@ -156,10 +174,22 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
156 }174 }
157 }175 }
158 176
177+func (d *Daemon) validRecentSessionAction(recent []recentSession, args []string) bool {
178+ if len(args) != 1 {
179+ return false
180+ }
181+ rank, err := strconv.Atoi(args[0])
182+ if err != nil || rank < 1 || rank > len(recent) {
183+ return false
184+ }
185+ return d.sessionByID(recent[rank-1].id) != nil
186+}
187+
159 type paletteExec struct {188 type paletteExec struct {
160- d *Daemon189+ d *Daemon
161- sess *session190+ sess *session
162- ac *attachedClient191+ ac *attachedClient
192+ recent []recentSession
163 }193 }
164 194
165 func (e paletteExec) CreateTab() error {195 func (e paletteExec) CreateTab() error {
@@ -289,6 +319,24 @@ func (e paletteExec) OpenSessionPicker() error {
289 return nil319 return nil
290 }320 }
291 321
322+func (e paletteExec) JumpRecentSession(args []string) error {
323+ if len(args) != 1 {
324+ return nil
325+ }
326+ rank, err := strconv.Atoi(args[0])
327+ if err != nil || rank < 1 || rank > len(e.recent) {
328+ return nil
329+ }
330+ // The snapshot is value-only; resolve the ID at execution so a removed
331+ // target is never resurrected through a retained session pointer.
332+ target := e.recent[rank-1]
333+ if e.d.sessionByID(target.id) == nil {
334+ return nil
335+ }
336+ e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
337+ return nil
338+}
339+
292 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {340 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
293 styleSet := resolveThemeStyles(styles)341 styleSet := resolveThemeStyles(styles)
294 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)342 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
M internal/usecase/daemon/palette_test.go +3 -2
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
1313 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/command"
1415 "github.com/bnema/vev/internal/usecase/palette"
1516 "github.com/bnema/vev/internal/usecase/ui"
1617 "github.com/bnema/vev/pkg/renderer"
@@ -138,7 +139,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
138139 for i, cmd := range commands {
139140 codes[i] = cmd.Code
140141 }
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+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "VIS", "RNS", "RNT", "DET"}, codes)
142143 }
143144
144145 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
@@ -159,7 +160,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
159160 wg.Wait()
160161
161162 commands := d.paletteCommands()
162- require.Len(t, commands, len(codes))
163+ require.Len(t, commands, len(command.Registry()))
163164 seen := map[string]bool{}
164165 for _, cmd := range commands {
165166 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/command"
14 "github.com/bnema/vev/internal/usecase/palette"15 "github.com/bnema/vev/internal/usecase/palette"
15 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
16 "github.com/bnema/vev/pkg/renderer"17 "github.com/bnema/vev/pkg/renderer"
@@ -138,7 +139,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
138 for i, cmd := range commands {139 for i, cmd := range commands {
139 codes[i] = cmd.Code140 codes[i] = cmd.Code
140 }141 }
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+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "VIS", "RNS", "RNT", "DET"}, codes)
142 }143 }
143 144
144 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {145 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
@@ -159,7 +160,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
159 wg.Wait()160 wg.Wait()
160 161
161 commands := d.paletteCommands()162 commands := d.paletteCommands()
162- require.Len(t, commands, len(codes))163+ require.Len(t, commands, len(command.Registry()))
163 seen := map[string]bool{}164 seen := map[string]bool{}
164 for _, cmd := range commands {165 for _, cmd := range commands {
165 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)166 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
A internal/usecase/daemon/recent_sessions.go +58 -0
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// recentSession is an immutable, value-only description of a session for one
10+// interaction. It deliberately does not retain a live session pointer.
11+type recentSession struct {
12+ id domain.SessionID
13+ name string
14+ ephemeral bool
15+ attention bool
16+ mruAt uint64
17+}
18+
19+// recentSessions returns the current session's capped MRU list. Callers may
20+// retain the returned values for the lifetime of an interaction.
21+func (d *Daemon) recentSessions(current *session) []recentSession {
22+ if d == nil {
23+ return nil
24+ }
25+ d.mu.Lock()
26+ recent := make([]recentSession, 0, len(d.sessions))
27+ for _, sess := range d.sessions {
28+ if sess == current {
29+ continue
30+ }
31+ sess.mu.Lock()
32+ entry := recentSession{
33+ id: sess.id,
34+ name: sess.name,
35+ ephemeral: sess.ephemeral,
36+ mruAt: sess.mruAt.Load(),
37+ }
38+ for _, tb := range sess.tabs {
39+ if tb.attention {
40+ entry.attention = true
41+ break
42+ }
43+ }
44+ sess.mu.Unlock()
45+ recent = append(recent, entry)
46+ }
47+ d.mu.Unlock()
48+ sort.SliceStable(recent, func(i, j int) bool {
49+ if recent[i].mruAt == recent[j].mruAt {
50+ return recent[i].name < recent[j].name
51+ }
52+ return recent[i].mruAt > recent[j].mruAt
53+ })
54+ if len(recent) > maxMRUSessions {
55+ recent = recent[:maxMRUSessions]
56+ }
57+ return recent
58+}
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// recentSession is an immutable, value-only description of a session for one
10+// interaction. It deliberately does not retain a live session pointer.
11+type recentSession struct {
12+ id domain.SessionID
13+ name string
14+ ephemeral bool
15+ attention bool
16+ mruAt uint64
17+}
18+
19+// recentSessions returns the current session's capped MRU list. Callers may
20+// retain the returned values for the lifetime of an interaction.
21+func (d *Daemon) recentSessions(current *session) []recentSession {
22+ if d == nil {
23+ return nil
24+ }
25+ d.mu.Lock()
26+ recent := make([]recentSession, 0, len(d.sessions))
27+ for _, sess := range d.sessions {
28+ if sess == current {
29+ continue
30+ }
31+ sess.mu.Lock()
32+ entry := recentSession{
33+ id: sess.id,
34+ name: sess.name,
35+ ephemeral: sess.ephemeral,
36+ mruAt: sess.mruAt.Load(),
37+ }
38+ for _, tb := range sess.tabs {
39+ if tb.attention {
40+ entry.attention = true
41+ break
42+ }
43+ }
44+ sess.mu.Unlock()
45+ recent = append(recent, entry)
46+ }
47+ d.mu.Unlock()
48+ sort.SliceStable(recent, func(i, j int) bool {
49+ if recent[i].mruAt == recent[j].mruAt {
50+ return recent[i].name < recent[j].name
51+ }
52+ return recent[i].mruAt > recent[j].mruAt
53+ })
54+ if len(recent) > maxMRUSessions {
55+ recent = recent[:maxMRUSessions]
56+ }
57+ return recent
58+}
M internal/usecase/daemon/status.go +4 -42
@@ -29,7 +29,6 @@ import (
2929 "strconv"
3030 "time"
3131
32- "github.com/bnema/vev/internal/domain"
3332 themeui "github.com/bnema/vev/internal/usecase/theme"
3433 "github.com/bnema/vev/internal/usecase/ui"
3534 "github.com/bnema/vev/pkg/renderer"
@@ -141,7 +140,7 @@ type barState struct {
141140 topRight string
142141 bottomRight string
143142 copyFeedback string
144- mru []mruSession
143+ mru []recentSession
145144 attentionFrame int
146145 // theme is the client's terminal theme, if reported. Its zero value
147146 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -149,15 +148,6 @@ type barState struct {
149148 theme themeui.Theme
150149 }
151150
152-type mruSession struct {
153- id domain.SessionID
154- name string
155- ephemeral bool
156- attention bool
157- // mruAt orders entries in barStateFor (freshest first); drawing ignores it.
158- mruAt uint64
159-}
160-
161151 type statusSnapshot struct {
162152 session string
163153 tabs []statusTab
@@ -216,35 +206,7 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
216206 if d == nil {
217207 return state
218208 }
219- d.mu.Lock()
220- mru := make([]mruSession, 0, len(d.sessions))
221- for _, sess := range d.sessions {
222- if sess == cur {
223- continue
224- }
225- at := sess.mruAt.Load()
226- sess.mu.Lock()
227- entry := mruSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
228- for _, tb := range sess.tabs {
229- if tb.attention {
230- entry.attention = true
231- break
232- }
233- }
234- sess.mu.Unlock()
235- mru = append(mru, entry)
236- }
237- d.mu.Unlock()
238- sort.SliceStable(mru, func(i, j int) bool {
239- if mru[i].mruAt == mru[j].mruAt {
240- return mru[i].name < mru[j].name
241- }
242- return mru[i].mruAt > mru[j].mruAt
243- })
244- if len(mru) > maxMRUSessions {
245- mru = mru[:maxMRUSessions]
246- }
247- state.mru = mru
209+ state.mru = d.recentSessions(cur)
248210 return state
249211 }
250212
@@ -310,7 +272,7 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
310272 return labels
311273 }
312274
313-func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
275+func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession {
314276 // With no feedback, keep one blank trailing cell; with feedback, reserve
315277 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
316278 copyReserve := 1
@@ -321,7 +283,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
321283 if physicalBudget <= 0 || len(entries) == 0 {
322284 return nil
323285 }
324- cost := func(e mruSession) int {
286+ cost := func(e recentSession) int {
325287 name := e.name
326288 if e.ephemeral {
327289 name += "*"
@@ -29,7 +29,6 @@ import (
29 "strconv"29 "strconv"
30 "time"30 "time"
31 31
32- "github.com/bnema/vev/internal/domain"
33 themeui "github.com/bnema/vev/internal/usecase/theme"32 themeui "github.com/bnema/vev/internal/usecase/theme"
34 "github.com/bnema/vev/internal/usecase/ui"33 "github.com/bnema/vev/internal/usecase/ui"
35 "github.com/bnema/vev/pkg/renderer"34 "github.com/bnema/vev/pkg/renderer"
@@ -141,7 +140,7 @@ type barState struct {
141 topRight string140 topRight string
142 bottomRight string141 bottomRight string
143 copyFeedback string142 copyFeedback string
144- mru []mruSession143+ mru []recentSession
145 attentionFrame int144 attentionFrame int
146 // theme is the client's terminal theme, if reported. Its zero value145 // theme is the client's terminal theme, if reported. Its zero value
147 // (Theme{}, Known: false) is a valid "no theme" default that resolves to146 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -149,15 +148,6 @@ type barState struct {
149 theme themeui.Theme148 theme themeui.Theme
150 }149 }
151 150
152-type mruSession struct {
153- id domain.SessionID
154- name string
155- ephemeral bool
156- attention bool
157- // mruAt orders entries in barStateFor (freshest first); drawing ignores it.
158- mruAt uint64
159-}
160-
161 type statusSnapshot struct {151 type statusSnapshot struct {
162 session string152 session string
163 tabs []statusTab153 tabs []statusTab
@@ -216,35 +206,7 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
216 if d == nil {206 if d == nil {
217 return state207 return state
218 }208 }
219- d.mu.Lock()209+ state.mru = d.recentSessions(cur)
220- mru := make([]mruSession, 0, len(d.sessions))
221- for _, sess := range d.sessions {
222- if sess == cur {
223- continue
224- }
225- at := sess.mruAt.Load()
226- sess.mu.Lock()
227- entry := mruSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
228- for _, tb := range sess.tabs {
229- if tb.attention {
230- entry.attention = true
231- break
232- }
233- }
234- sess.mu.Unlock()
235- mru = append(mru, entry)
236- }
237- d.mu.Unlock()
238- sort.SliceStable(mru, func(i, j int) bool {
239- if mru[i].mruAt == mru[j].mruAt {
240- return mru[i].name < mru[j].name
241- }
242- return mru[i].mruAt > mru[j].mruAt
243- })
244- if len(mru) > maxMRUSessions {
245- mru = mru[:maxMRUSessions]
246- }
247- state.mru = mru
248 return state210 return state
249 }211 }
250 212
@@ -310,7 +272,7 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
310 return labels272 return labels
311 }273 }
312 274
313-func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {275+func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession {
314 // With no feedback, keep one blank trailing cell; with feedback, reserve276 // With no feedback, keep one blank trailing cell; with feedback, reserve
315 // its " text" width plus a one-cell gap so drawRightPlainText always fits.277 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
316 copyReserve := 1278 copyReserve := 1
@@ -321,7 +283,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
321 if physicalBudget <= 0 || len(entries) == 0 {283 if physicalBudget <= 0 || len(entries) == 0 {
322 return nil284 return nil
323 }285 }
324- cost := func(e mruSession) int {286+ cost := func(e recentSession) int {
325 name := e.name287 name := e.name
326 if e.ephemeral {288 if e.ephemeral {
327 name += "*"289 name += "*"
M internal/usecase/daemon/status_test.go +13 -13
@@ -828,7 +828,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
828828 state := barState{
829829 status: statusSnapshot{session: "cur"},
830830 attentionFrame: 1,
831- mru: []mruSession{
831+ mru: []recentSession{
832832 {name: "fresh"},
833833 {name: "tmp", ephemeral: true, attention: true},
834834 },
@@ -863,7 +863,7 @@ func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
863863 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
864864 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
865865 styles := newThemeStyles(theme)
866- state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
866+ state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
867867 row := make([]renderer.Cell, 16)
868868
869869 drawStatusBarState(row, state, styles)
@@ -888,7 +888,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
888888 }
889889
890890 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
891- state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
891+ state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
892892 row := make([]renderer.Cell, 21)
893893
894894 drawStatusBarState(row, state, resolveThemeStyles(nil))
@@ -900,9 +900,9 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
900900 }
901901
902902 func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
903- mru := make([]mruSession, 0, maxMRUSessions)
903+ mru := make([]recentSession, 0, maxMRUSessions)
904904 for i := 1; i <= maxMRUSessions; i++ {
905- mru = append(mru, mruSession{name: "recent" + strconv.Itoa(i)})
905+ mru = append(mru, recentSession{name: "recent" + strconv.Itoa(i)})
906906 }
907907 state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
908908
@@ -962,27 +962,27 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
962962 {
963963 name: "script text and copy feedback",
964964 width: 32,
965- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}},
965+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "a"}}},
966966 wantContains: []string{" a"},
967967 wantSuffix: " main ↑3 * copied",
968968 },
969969 {
970970 name: "hide on overlap",
971971 width: 16,
972- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
972+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
973973 want: " cur ",
974974 wantNotContains: []string{"main", "copied"},
975975 },
976976 {
977977 name: "empty script keeps copy feedback behavior",
978978 width: 12,
979- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
979+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
980980 want: " cur copied",
981981 },
982982 {
983983 name: "mru whole entry fitting with script text",
984984 width: 24,
985- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
985+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
986986 wantContains: []string{" fresh"},
987987 wantNotContains: []string{"middle"},
988988 wantSuffix: " git",
@@ -990,7 +990,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
990990 {
991991 name: "mru fitting reserves wide right anchor width",
992992 width: 12,
993- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}},
993+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentSession{{name: "a"}}},
994994 wantNotContains: []string{" a "},
995995 wantSuffix: " 界 界 ",
996996 continuationCells: []int{9, 11},
@@ -998,7 +998,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
998998 {
999999 name: "mru fitting counts wide session names",
10001000 width: 20,
1001- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "界"}, {name: "界"}, {name: "b"}}},
1001+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "界"}, {name: "界"}, {name: "b"}}},
10021002 wantContains: []string{" 界 "},
10031003 wantNotContains: []string{" b "},
10041004 wantSuffix: " git",
@@ -1031,7 +1031,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
10311031 }
10321032
10331033 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1034- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1034+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
10351035 row := make([]renderer.Cell, 20)
10361036
10371037 drawStatusBarState(row, state, resolveThemeStyles(nil))
@@ -1044,7 +1044,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
10441044 }
10451045
10461046 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1047- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
1047+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}}
10481048 tests := []struct {
10491049 name string
10501050 cols int
@@ -828,7 +828,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
828 state := barState{828 state := barState{
829 status: statusSnapshot{session: "cur"},829 status: statusSnapshot{session: "cur"},
830 attentionFrame: 1,830 attentionFrame: 1,
831- mru: []mruSession{831+ mru: []recentSession{
832 {name: "fresh"},832 {name: "fresh"},
833 {name: "tmp", ephemeral: true, attention: true},833 {name: "tmp", ephemeral: true, attention: true},
834 },834 },
@@ -863,7 +863,7 @@ func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
863 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {863 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
864 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}864 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
865 styles := newThemeStyles(theme)865 styles := newThemeStyles(theme)
866- state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}866+ state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
867 row := make([]renderer.Cell, 16)867 row := make([]renderer.Cell, 16)
868 868
869 drawStatusBarState(row, state, styles)869 drawStatusBarState(row, state, styles)
@@ -888,7 +888,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
888 }888 }
889 889
890 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {890 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
891- state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}891+ state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
892 row := make([]renderer.Cell, 21)892 row := make([]renderer.Cell, 21)
893 893
894 drawStatusBarState(row, state, resolveThemeStyles(nil))894 drawStatusBarState(row, state, resolveThemeStyles(nil))
@@ -900,9 +900,9 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
900 }900 }
901 901
902 func TestStatusBarMRUWidthAwareBudget(t *testing.T) {902 func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
903- mru := make([]mruSession, 0, maxMRUSessions)903+ mru := make([]recentSession, 0, maxMRUSessions)
904 for i := 1; i <= maxMRUSessions; i++ {904 for i := 1; i <= maxMRUSessions; i++ {
905- mru = append(mru, mruSession{name: "recent" + strconv.Itoa(i)})905+ mru = append(mru, recentSession{name: "recent" + strconv.Itoa(i)})
906 }906 }
907 state := barState{status: statusSnapshot{session: "cur"}, mru: mru}907 state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
908 908
@@ -962,27 +962,27 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
962 {962 {
963 name: "script text and copy feedback",963 name: "script text and copy feedback",
964 width: 32,964 width: 32,
965- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}},965+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "a"}}},
966 wantContains: []string{" a"},966 wantContains: []string{" a"},
967 wantSuffix: " main ↑3 * copied",967 wantSuffix: " main ↑3 * copied",
968 },968 },
969 {969 {
970 name: "hide on overlap",970 name: "hide on overlap",
971 width: 16,971 width: 16,
972- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},972+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
973 want: " cur ",973 want: " cur ",
974 wantNotContains: []string{"main", "copied"},974 wantNotContains: []string{"main", "copied"},
975 },975 },
976 {976 {
977 name: "empty script keeps copy feedback behavior",977 name: "empty script keeps copy feedback behavior",
978 width: 12,978 width: 12,
979- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},979+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
980 want: " cur copied",980 want: " cur copied",
981 },981 },
982 {982 {
983 name: "mru whole entry fitting with script text",983 name: "mru whole entry fitting with script text",
984 width: 24,984 width: 24,
985- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},985+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
986 wantContains: []string{" fresh"},986 wantContains: []string{" fresh"},
987 wantNotContains: []string{"middle"},987 wantNotContains: []string{"middle"},
988 wantSuffix: " git",988 wantSuffix: " git",
@@ -990,7 +990,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
990 {990 {
991 name: "mru fitting reserves wide right anchor width",991 name: "mru fitting reserves wide right anchor width",
992 width: 12,992 width: 12,
993- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}},993+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentSession{{name: "a"}}},
994 wantNotContains: []string{" a "},994 wantNotContains: []string{" a "},
995 wantSuffix: " 界 界 ",995 wantSuffix: " 界 界 ",
996 continuationCells: []int{9, 11},996 continuationCells: []int{9, 11},
@@ -998,7 +998,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
998 {998 {
999 name: "mru fitting counts wide session names",999 name: "mru fitting counts wide session names",
1000 width: 20,1000 width: 20,
1001- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "界"}, {name: "界"}, {name: "b"}}},1001+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "界"}, {name: "界"}, {name: "b"}}},
1002 wantContains: []string{" 界 "},1002 wantContains: []string{" 界 "},
1003 wantNotContains: []string{" b "},1003 wantNotContains: []string{" b "},
1004 wantSuffix: " git",1004 wantSuffix: " git",
@@ -1031,7 +1031,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1031 }1031 }
1032 1032
1033 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {1033 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1034- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}1034+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1035 row := make([]renderer.Cell, 20)1035 row := make([]renderer.Cell, 20)
1036 1036
1037 drawStatusBarState(row, state, resolveThemeStyles(nil))1037 drawStatusBarState(row, state, resolveThemeStyles(nil))
@@ -1044,7 +1044,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1044 }1044 }
1045 1045
1046 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {1046 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1047- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}1047+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}}
1048 tests := []struct {1048 tests := []struct {
1049 name string1049 name string
1050 cols int1050 cols int
A internal/usecase/palette/action.go +57 -0
@@ -0,0 +1,57 @@
1+package palette
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+// Action is the exact command token and its unmodified, whitespace-delimited
10+// arguments. It is deliberately independent of palette state.
11+type Action struct {
12+ Command command.Command
13+ Args []string
14+}
15+
16+// ParseAction recognizes an exact effective command token. It accepts outer
17+// and repeated whitespace but never treats a prefix or concatenated token as
18+// a command. Static commands reject arguments.
19+func ParseAction(commands []command.Command, input string) (Action, bool) {
20+ fields := strings.Fields(input)
21+ if len(fields) == 0 {
22+ return Action{}, false
23+ }
24+ for _, cmd := range commands {
25+ if !strings.EqualFold(fields[0], cmd.Code) {
26+ continue
27+ }
28+ args := append([]string(nil), fields[1:]...)
29+ switch cmd.Arguments {
30+ case command.ArgumentsNone:
31+ if len(args) != 0 {
32+ return Action{}, false
33+ }
34+ case command.ArgumentsRequired:
35+ if len(args) == 0 {
36+ return Action{}, false
37+ }
38+ }
39+ return Action{Command: cmd, Args: args}, true
40+ }
41+ return Action{}, false
42+}
43+
44+// ArgumentCommand returns the exact argument-taking command whose token is
45+// being typed, including the whitespace-before-argument state.
46+func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) {
47+ fields := strings.Fields(input)
48+ if len(fields) == 0 {
49+ return command.Command{}, false
50+ }
51+ for _, cmd := range commands {
52+ if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
53+ return cmd, true
54+ }
55+ }
56+ return command.Command{}, false
57+}
@@ -0,0 +1,57 @@
1+package palette
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+// Action is the exact command token and its unmodified, whitespace-delimited
10+// arguments. It is deliberately independent of palette state.
11+type Action struct {
12+ Command command.Command
13+ Args []string
14+}
15+
16+// ParseAction recognizes an exact effective command token. It accepts outer
17+// and repeated whitespace but never treats a prefix or concatenated token as
18+// a command. Static commands reject arguments.
19+func ParseAction(commands []command.Command, input string) (Action, bool) {
20+ fields := strings.Fields(input)
21+ if len(fields) == 0 {
22+ return Action{}, false
23+ }
24+ for _, cmd := range commands {
25+ if !strings.EqualFold(fields[0], cmd.Code) {
26+ continue
27+ }
28+ args := append([]string(nil), fields[1:]...)
29+ switch cmd.Arguments {
30+ case command.ArgumentsNone:
31+ if len(args) != 0 {
32+ return Action{}, false
33+ }
34+ case command.ArgumentsRequired:
35+ if len(args) == 0 {
36+ return Action{}, false
37+ }
38+ }
39+ return Action{Command: cmd, Args: args}, true
40+ }
41+ return Action{}, false
42+}
43+
44+// ArgumentCommand returns the exact argument-taking command whose token is
45+// being typed, including the whitespace-before-argument state.
46+func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) {
47+ fields := strings.Fields(input)
48+ if len(fields) == 0 {
49+ return command.Command{}, false
50+ }
51+ for _, cmd := range commands {
52+ if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
53+ return cmd, true
54+ }
55+ }
56+ return command.Command{}, false
57+}
A internal/usecase/palette/action_test.go +61 -0
@@ -0,0 +1,61 @@
1+package palette
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+func TestParseActionExactEffectiveToken(t *testing.T) {
10+ commands := []command.Command{
11+ {Code: "BSK", Arguments: command.ArgumentsNone},
12+ {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
13+ }
14+ tests := []struct {
15+ name string
16+ input string
17+ ok bool
18+ code string
19+ args []string
20+ }{
21+ {name: "static token ignores case and whitespace", input: " bSk ", ok: true, code: "BSK"},
22+ {name: "required arguments accept repeated whitespace", input: " JRS 2 ", ok: true, code: "JRS", args: []string{"2"}},
23+ {name: "partial token rejected", input: "JR", ok: false},
24+ {name: "concatenated token rejected", input: "JRS2", ok: false},
25+ {name: "static arguments rejected", input: "BSK 2", ok: false},
26+ {name: "missing required argument rejected", input: "JRS", ok: false},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ action, ok := ParseAction(commands, tt.input)
31+ if ok != tt.ok {
32+ t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)
33+ }
34+ if !ok {
35+ return
36+ }
37+ if action.Command.Code != tt.code || !sameStrings(action.Args, tt.args) {
38+ t.Fatalf("ParseAction(%q) = %#v, want %s %#v", tt.input, action, tt.code, tt.args)
39+ }
40+ })
41+ }
42+}
43+
44+func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
45+ cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
46+ if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" {
47+ t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)
48+ }
49+}
50+
51+func sameStrings(a, b []string) bool {
52+ if len(a) != len(b) {
53+ return false
54+ }
55+ for i := range a {
56+ if a[i] != b[i] {
57+ return false
58+ }
59+ }
60+ return true
61+}
@@ -0,0 +1,61 @@
1+package palette
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+func TestParseActionExactEffectiveToken(t *testing.T) {
10+ commands := []command.Command{
11+ {Code: "BSK", Arguments: command.ArgumentsNone},
12+ {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
13+ }
14+ tests := []struct {
15+ name string
16+ input string
17+ ok bool
18+ code string
19+ args []string
20+ }{
21+ {name: "static token ignores case and whitespace", input: " bSk ", ok: true, code: "BSK"},
22+ {name: "required arguments accept repeated whitespace", input: " JRS 2 ", ok: true, code: "JRS", args: []string{"2"}},
23+ {name: "partial token rejected", input: "JR", ok: false},
24+ {name: "concatenated token rejected", input: "JRS2", ok: false},
25+ {name: "static arguments rejected", input: "BSK 2", ok: false},
26+ {name: "missing required argument rejected", input: "JRS", ok: false},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ action, ok := ParseAction(commands, tt.input)
31+ if ok != tt.ok {
32+ t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)
33+ }
34+ if !ok {
35+ return
36+ }
37+ if action.Command.Code != tt.code || !sameStrings(action.Args, tt.args) {
38+ t.Fatalf("ParseAction(%q) = %#v, want %s %#v", tt.input, action, tt.code, tt.args)
39+ }
40+ })
41+ }
42+}
43+
44+func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
45+ cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
46+ if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" {
47+ t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)
48+ }
49+}
50+
51+func sameStrings(a, b []string) bool {
52+ if len(a) != len(b) {
53+ return false
54+ }
55+ for i := range a {
56+ if a[i] != b[i] {
57+ return false
58+ }
59+ }
60+ return true
61+}
M internal/usecase/palette/model.go +18 -1
@@ -88,7 +88,24 @@ func (m *Model) Matches() []Match {
8888 return out
8989 }
9090
91-func (m *Model) refresh() { m.matches = Fuzzy(m.commands, m.input.Value()); m.clamp() }
91+func (m *Model) refresh() {
92+ m.matches = Fuzzy(m.commands, m.input.Value())
93+ // Once an argument-taking token is exact, retain its row while its
94+ // arguments make ordinary fuzzy matching inapplicable.
95+ if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok {
96+ found := false
97+ for _, match := range m.matches {
98+ if match.Command.Code == cmd.Code {
99+ found = true
100+ break
101+ }
102+ }
103+ if !found {
104+ m.matches = append([]Match{{Command: cmd}}, m.matches...)
105+ }
106+ }
107+ m.clamp()
108+}
92109 func (m *Model) clamp() {
93110 if len(m.matches) == 0 {
94111 m.selected = -1
@@ -88,7 +88,24 @@ func (m *Model) Matches() []Match {
88 return out88 return out
89 }89 }
90 90
91-func (m *Model) refresh() { m.matches = Fuzzy(m.commands, m.input.Value()); m.clamp() }91+func (m *Model) refresh() {
92+ m.matches = Fuzzy(m.commands, m.input.Value())
93+ // Once an argument-taking token is exact, retain its row while its
94+ // arguments make ordinary fuzzy matching inapplicable.
95+ if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok {
96+ found := false
97+ for _, match := range m.matches {
98+ if match.Command.Code == cmd.Code {
99+ found = true
100+ break
101+ }
102+ }
103+ if !found {
104+ m.matches = append([]Match{{Command: cmd}}, m.matches...)
105+ }
106+ }
107+ m.clamp()
108+}
92 func (m *Model) clamp() {109 func (m *Model) clamp() {
93 if len(m.matches) == 0 {110 if len(m.matches) == 0 {
94 m.selected = -1111 m.selected = -1

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