feat(session): jump to recent sessions by rank
- commit
- 1697b4cc59c5f4923afa1d91a8009b223a38a4a6
- author
- brice · · 2026-07-11 12:03 UTC
- committer
- brice · · 2026-07-11 12:03 UTC
- parent
- 54b2176
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +264 -94
M internal/usecase/command/command.go +27 -1
| @@ -1,5 +1,31 @@ | |||
| 1 | 1 | package command | |
| 2 | 2 | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "strconv" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // ErrInvalidArguments reports arguments that do not have the exact command format. | |
| 9 | + | var ErrInvalidArguments = errors.New("invalid command arguments") | |
| 10 | + | ||
| 11 | + | // ParsePositiveDecimal accepts exactly one base-10 positive decimal value. | |
| 12 | + | // It deliberately rejects signs, whitespace, zero, and non-canonical forms. | |
| 13 | + | func ParsePositiveDecimal(args []string) (int, error) { | |
| 14 | + | if len(args) != 1 || args[0] == "" { | |
| 15 | + | return 0, ErrInvalidArguments | |
| 16 | + | } | |
| 17 | + | for _, r := range args[0] { | |
| 18 | + | if r < '0' || r > '9' { | |
| 19 | + | return 0, ErrInvalidArguments | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | n, err := strconv.Atoi(args[0]) | |
| 23 | + | if err != nil || n < 1 { | |
| 24 | + | return 0, ErrInvalidArguments | |
| 25 | + | } | |
| 26 | + | return n, nil | |
| 27 | + | } | |
| 28 | + | ||
| 3 | 29 | // Context is the set of application actions available to commands. | |
| 4 | 30 | type Context interface { | |
| 5 | 31 | CreateTab() error | |
| @@ -25,7 +51,7 @@ type Context interface { | |||
| 25 | 51 | RenameSession() error | |
| 26 | 52 | RenameTab() error | |
| 27 | 53 | OpenSessionPicker() error | |
| 28 | - | JumpRecentSession(args []string) error | |
| 54 | + | JumpRecentSession(rank int) error | |
| 29 | 55 | } | |
| 30 | 56 | ||
| 31 | 57 | // Arguments declares whether a command accepts palette arguments. | |
| @@ -1,5 +1,31 @@ | |||||
| 1 | package command | 1 | package command | ||
| 2 | 2 | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "strconv" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // ErrInvalidArguments reports arguments that do not have the exact command format. | |||
| 9 | + | var ErrInvalidArguments = errors.New("invalid command arguments") | |||
| 10 | + | ||||
| 11 | + | // ParsePositiveDecimal accepts exactly one base-10 positive decimal value. | |||
| 12 | + | // It deliberately rejects signs, whitespace, zero, and non-canonical forms. | |||
| 13 | + | func ParsePositiveDecimal(args []string) (int, error) { | |||
| 14 | + | if len(args) != 1 || args[0] == "" { | |||
| 15 | + | return 0, ErrInvalidArguments | |||
| 16 | + | } | |||
| 17 | + | for _, r := range args[0] { | |||
| 18 | + | if r < '0' || r > '9' { | |||
| 19 | + | return 0, ErrInvalidArguments | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | n, err := strconv.Atoi(args[0]) | |||
| 23 | + | if err != nil || n < 1 { | |||
| 24 | + | return 0, ErrInvalidArguments | |||
| 25 | + | } | |||
| 26 | + | return n, nil | |||
| 27 | + | } | |||
| 28 | + | ||||
| 3 | // Context is the set of application actions available to commands. | 29 | // Context is the set of application actions available to commands. | ||
| 4 | type Context interface { | 30 | type Context interface { | ||
| 5 | CreateTab() error | 31 | CreateTab() error | ||
| @@ -25,7 +51,7 @@ type Context interface { | |||||
| 25 | RenameSession() error | 51 | RenameSession() error | ||
| 26 | RenameTab() error | 52 | RenameTab() error | ||
| 27 | OpenSessionPicker() error | 53 | OpenSessionPicker() error | ||
| 28 | - | JumpRecentSession(args []string) error | 54 | + | JumpRecentSession(rank int) error |
| 29 | } | 55 | } | ||
| 30 | 56 | ||||
| 31 | // Arguments declares whether a command accepts palette arguments. | 57 | // Arguments declares whether a command accepts palette arguments. | ||
A internal/usecase/command/command_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package command | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestParsePositiveDecimal(t *testing.T) { | |
| 9 | + | for _, tt := range []struct { | |
| 10 | + | name string | |
| 11 | + | args []string | |
| 12 | + | want int | |
| 13 | + | invalid bool | |
| 14 | + | }{ | |
| 15 | + | {"one", []string{"1"}, 1, false}, | |
| 16 | + | {"zero", []string{"0"}, 0, true}, | |
| 17 | + | {"negative", []string{"-1"}, 0, true}, | |
| 18 | + | {"extra", []string{"1", "2"}, 0, true}, | |
| 19 | + | {"space", []string{" 1"}, 0, true}, | |
| 20 | + | } { | |
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |
| 22 | + | got, err := ParsePositiveDecimal(tt.args) | |
| 23 | + | if tt.invalid { | |
| 24 | + | if !errors.Is(err, ErrInvalidArguments) { | |
| 25 | + | t.Fatalf("error = %v", err) | |
| 26 | + | } | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | if err != nil || got != tt.want { | |
| 30 | + | t.Fatalf("ParsePositiveDecimal() = %d, %v", got, err) | |
| 31 | + | } | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package command | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestParsePositiveDecimal(t *testing.T) { | |||
| 9 | + | for _, tt := range []struct { | |||
| 10 | + | name string | |||
| 11 | + | args []string | |||
| 12 | + | want int | |||
| 13 | + | invalid bool | |||
| 14 | + | }{ | |||
| 15 | + | {"one", []string{"1"}, 1, false}, | |||
| 16 | + | {"zero", []string{"0"}, 0, true}, | |||
| 17 | + | {"negative", []string{"-1"}, 0, true}, | |||
| 18 | + | {"extra", []string{"1", "2"}, 0, true}, | |||
| 19 | + | {"space", []string{" 1"}, 0, true}, | |||
| 20 | + | } { | |||
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 22 | + | got, err := ParsePositiveDecimal(tt.args) | |||
| 23 | + | if tt.invalid { | |||
| 24 | + | if !errors.Is(err, ErrInvalidArguments) { | |||
| 25 | + | t.Fatalf("error = %v", err) | |||
| 26 | + | } | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | if err != nil || got != tt.want { | |||
| 30 | + | t.Fatalf("ParsePositiveDecimal() = %d, %v", got, err) | |||
| 31 | + | } | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/usecase/command/context_mock_test.go +11 -11
| @@ -520,16 +520,16 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont | |||
| 520 | 520 | } | |
| 521 | 521 | ||
| 522 | 522 | // JumpRecentSession provides a mock function for the type MockContext | |
| 523 | - | func (_mock *MockContext) JumpRecentSession(args []string) error { | |
| 524 | - | ret := _mock.Called(args) | |
| 523 | + | func (_mock *MockContext) JumpRecentSession(rank int) error { | |
| 524 | + | ret := _mock.Called(rank) | |
| 525 | 525 | ||
| 526 | 526 | if len(ret) == 0 { | |
| 527 | 527 | panic("no return value specified for JumpRecentSession") | |
| 528 | 528 | } | |
| 529 | 529 | ||
| 530 | 530 | var r0 error | |
| 531 | - | if returnFunc, ok := ret.Get(0).(func([]string) error); ok { | |
| 532 | - | r0 = returnFunc(args) | |
| 531 | + | if returnFunc, ok := ret.Get(0).(func(int) error); ok { | |
| 532 | + | r0 = returnFunc(rank) | |
| 533 | 533 | } else { | |
| 534 | 534 | r0 = ret.Error(0) | |
| 535 | 535 | } | |
| @@ -542,16 +542,16 @@ type MockContext_JumpRecentSession_Call struct { | |||
| 542 | 542 | } | |
| 543 | 543 | ||
| 544 | 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)} | |
| 545 | + | // - rank int | |
| 546 | + | func (_e *MockContext_Expecter) JumpRecentSession(rank any) *MockContext_JumpRecentSession_Call { | |
| 547 | + | return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", rank)} | |
| 548 | 548 | } | |
| 549 | 549 | ||
| 550 | - | func (_c *MockContext_JumpRecentSession_Call) Run(run func(args []string)) *MockContext_JumpRecentSession_Call { | |
| 550 | + | func (_c *MockContext_JumpRecentSession_Call) Run(run func(rank int)) *MockContext_JumpRecentSession_Call { | |
| 551 | 551 | _c.Call.Run(func(args mock.Arguments) { | |
| 552 | - | var arg0 []string | |
| 552 | + | var arg0 int | |
| 553 | 553 | if args[0] != nil { | |
| 554 | - | arg0 = args[0].([]string) | |
| 554 | + | arg0 = args[0].(int) | |
| 555 | 555 | } | |
| 556 | 556 | run( | |
| 557 | 557 | arg0, | |
| @@ -565,7 +565,7 @@ func (_c *MockContext_JumpRecentSession_Call) Return(err error) *MockContext_Jum | |||
| 565 | 565 | return _c | |
| 566 | 566 | } | |
| 567 | 567 | ||
| 568 | - | func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(args []string) error) *MockContext_JumpRecentSession_Call { | |
| 568 | + | func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(rank int) error) *MockContext_JumpRecentSession_Call { | |
| 569 | 569 | _c.Call.Return(run) | |
| 570 | 570 | return _c | |
| 571 | 571 | } | |
| @@ -520,16 +520,16 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont | |||||
| 520 | } | 520 | } | ||
| 521 | 521 | ||||
| 522 | // JumpRecentSession provides a mock function for the type MockContext | 522 | // JumpRecentSession provides a mock function for the type MockContext | ||
| 523 | - | func (_mock *MockContext) JumpRecentSession(args []string) error { | 523 | + | func (_mock *MockContext) JumpRecentSession(rank int) error { |
| 524 | - | ret := _mock.Called(args) | 524 | + | ret := _mock.Called(rank) |
| 525 | 525 | ||||
| 526 | if len(ret) == 0 { | 526 | if len(ret) == 0 { | ||
| 527 | panic("no return value specified for JumpRecentSession") | 527 | panic("no return value specified for JumpRecentSession") | ||
| 528 | } | 528 | } | ||
| 529 | 529 | ||||
| 530 | var r0 error | 530 | var r0 error | ||
| 531 | - | if returnFunc, ok := ret.Get(0).(func([]string) error); ok { | 531 | + | if returnFunc, ok := ret.Get(0).(func(int) error); ok { |
| 532 | - | r0 = returnFunc(args) | 532 | + | r0 = returnFunc(rank) |
| 533 | } else { | 533 | } else { | ||
| 534 | r0 = ret.Error(0) | 534 | r0 = ret.Error(0) | ||
| 535 | } | 535 | } | ||
| @@ -542,16 +542,16 @@ type MockContext_JumpRecentSession_Call struct { | |||||
| 542 | } | 542 | } | ||
| 543 | 543 | ||||
| 544 | // JumpRecentSession is a helper method to define mock.On call | 544 | // JumpRecentSession is a helper method to define mock.On call | ||
| 545 | - | // - args []string | 545 | + | // - rank int |
| 546 | - | func (_e *MockContext_Expecter) JumpRecentSession(args any) *MockContext_JumpRecentSession_Call { | 546 | + | func (_e *MockContext_Expecter) JumpRecentSession(rank any) *MockContext_JumpRecentSession_Call { |
| 547 | - | return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", args)} | 547 | + | return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", rank)} |
| 548 | } | 548 | } | ||
| 549 | 549 | ||||
| 550 | - | func (_c *MockContext_JumpRecentSession_Call) Run(run func(args []string)) *MockContext_JumpRecentSession_Call { | 550 | + | func (_c *MockContext_JumpRecentSession_Call) Run(run func(rank int)) *MockContext_JumpRecentSession_Call { |
| 551 | _c.Call.Run(func(args mock.Arguments) { | 551 | _c.Call.Run(func(args mock.Arguments) { | ||
| 552 | - | var arg0 []string | 552 | + | var arg0 int |
| 553 | if args[0] != nil { | 553 | if args[0] != nil { | ||
| 554 | - | arg0 = args[0].([]string) | 554 | + | arg0 = args[0].(int) |
| 555 | } | 555 | } | ||
| 556 | run( | 556 | run( | ||
| 557 | arg0, | 557 | arg0, | ||
| @@ -565,7 +565,7 @@ func (_c *MockContext_JumpRecentSession_Call) Return(err error) *MockContext_Jum | |||||
| 565 | return _c | 565 | return _c | ||
| 566 | } | 566 | } | ||
| 567 | 567 | ||||
| 568 | - | func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(args []string) error) *MockContext_JumpRecentSession_Call { | 568 | + | func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(rank int) error) *MockContext_JumpRecentSession_Call { |
| 569 | _c.Call.Return(run) | 569 | _c.Call.Return(run) | ||
| 570 | return _c | 570 | return _c | ||
| 571 | } | 571 | } | ||
M internal/usecase/command/registry.go +7 -1
| @@ -23,7 +23,13 @@ func Registry() []Command { | |||
| 23 | 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 | 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 | 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) }}, | |
| 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 { | |
| 27 | + | rank, err := ParsePositiveDecimal(args) | |
| 28 | + | if err != nil { | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | return ctx.JumpRecentSession(rank) | |
| 32 | + | }}, | |
| 27 | 33 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | |
| 28 | 34 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | |
| 29 | 35 | {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() }}, | |
| @@ -23,7 +23,13 @@ func Registry() []Command { | |||||
| 23 | {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) 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, _ []string) 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, _ []string) 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: "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) }}, | 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 { |
| 27 | + | rank, err := ParsePositiveDecimal(args) | |||
| 28 | + | if err != nil { | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 31 | + | return ctx.JumpRecentSession(rank) | |||
| 32 | + | }}, | |||
| 27 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | 33 | {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() }}, | 34 | {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() }}, | 35 | {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() }}, | ||
M internal/usecase/command/registry_test.go +6 -2
| @@ -86,7 +86,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 86 | 86 | {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }}, | |
| 87 | 87 | {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }}, | |
| 88 | 88 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | |
| 89 | - | {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession([]string(nil)).Return(nil).Once() }}, | |
| 89 | + | {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }}, | |
| 90 | 90 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | |
| 91 | 91 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | |
| 92 | 92 | {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }}, | |
| @@ -103,7 +103,11 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 103 | 103 | ||
| 104 | 104 | ctx := NewMockContext(t) | |
| 105 | 105 | tt.expect(ctx) | |
| 106 | - | if err := cmd.Run(ctx, nil); err != nil { | |
| 106 | + | args := []string(nil) | |
| 107 | + | if tt.code == "JRS" { | |
| 108 | + | args = []string{"1"} | |
| 109 | + | } | |
| 110 | + | if err := cmd.Run(ctx, args); err != nil { | |
| 107 | 111 | t.Fatalf("Run() error = %v, want nil", err) | |
| 108 | 112 | } | |
| 109 | 113 | }) | |
| @@ -86,7 +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: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }}, |
| 90 | {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() }}, | ||
| 91 | {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() }}, | ||
| 92 | {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() }}, | ||
| @@ -103,7 +103,11 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 103 | 103 | ||||
| 104 | ctx := NewMockContext(t) | 104 | ctx := NewMockContext(t) | ||
| 105 | tt.expect(ctx) | 105 | tt.expect(ctx) | ||
| 106 | - | if err := cmd.Run(ctx, nil); err != nil { | 106 | + | args := []string(nil) |
| 107 | + | if tt.code == "JRS" { | |||
| 108 | + | args = []string{"1"} | |||
| 109 | + | } | |||
| 110 | + | if err := cmd.Run(ctx, args); err != nil { | |||
| 107 | t.Fatalf("Run() error = %v, want nil", err) | 111 | t.Fatalf("Run() error = %v, want nil", err) | ||
| 108 | } | 112 | } | ||
| 109 | }) | 113 | }) | ||
M internal/usecase/daemon/overlay_runtime.go +7 -4
| @@ -19,10 +19,13 @@ type overlayRuntime struct { | |||
| 19 | 19 | pickerPending []byte | |
| 20 | 20 | pickerESC pendingByteTimer | |
| 21 | 21 | ||
| 22 | - | paletteMu sync.Mutex | |
| 23 | - | palette *palette.Model | |
| 24 | - | paletteRecent []recentSession | |
| 25 | - | palettePending []byte | |
| 22 | + | paletteMu sync.Mutex | |
| 23 | + | palette *palette.Model | |
| 24 | + | paletteRecent []recentSession // immutable for this palette interaction | |
| 25 | + | paletteGeneration uint64 | |
| 26 | + | paletteHints palette.ContextualHints | |
| 27 | + | paletteFeedback string | |
| 28 | + | palettePending []byte | |
| 26 | 29 | ||
| 27 | 30 | promptMu sync.Mutex | |
| 28 | 31 | prompt *promptui.Model | |
| @@ -19,10 +19,13 @@ type overlayRuntime struct { | |||||
| 19 | pickerPending []byte | 19 | pickerPending []byte | ||
| 20 | pickerESC pendingByteTimer | 20 | pickerESC pendingByteTimer | ||
| 21 | 21 | ||||
| 22 | - | paletteMu sync.Mutex | 22 | + | paletteMu sync.Mutex |
| 23 | - | palette *palette.Model | 23 | + | palette *palette.Model |
| 24 | - | paletteRecent []recentSession | 24 | + | paletteRecent []recentSession // immutable for this palette interaction |
| 25 | - | palettePending []byte | 25 | + | paletteGeneration uint64 |
| 26 | + | paletteHints palette.ContextualHints | |||
| 27 | + | paletteFeedback string | |||
| 28 | + | palettePending []byte | |||
| 26 | 29 | ||||
| 27 | promptMu sync.Mutex | 30 | promptMu sync.Mutex | ||
| 28 | prompt *promptui.Model | 31 | prompt *promptui.Model | ||
M internal/usecase/daemon/palette.go +111 -71
| @@ -1,8 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "strconv" | |
| 5 | - | ||
| 6 | 4 | "github.com/bnema/vev/internal/domain" | |
| 7 | 5 | "github.com/bnema/vev/internal/usecase/command" | |
| 8 | 6 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -33,21 +31,27 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | |||
| 33 | 31 | } | |
| 34 | 32 | ||
| 35 | 33 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |
| 36 | - | d.closePalette(ac) | |
| 34 | + | // Capture daemon/session state before taking paletteMu: lock ordering forbids | |
| 35 | + | // holding an overlay lock while inspecting live sessions. | |
| 36 | + | recent := d.recentSessions(sess) | |
| 37 | + | commands := d.paletteCommands() | |
| 37 | 38 | ac.overlays.paletteMu.Lock() | |
| 38 | - | ac.overlays.palette = palette.New(d.paletteCommands()) | |
| 39 | - | ac.overlays.paletteRecent = d.recentSessions(sess) | |
| 39 | + | ac.overlays.paletteGeneration++ | |
| 40 | + | ac.overlays.palette = palette.New(commands) | |
| 41 | + | ac.overlays.paletteRecent = recent | |
| 42 | + | ac.overlays.paletteHints = recentSessionHints(recent, nil) | |
| 43 | + | ac.overlays.paletteFeedback = "" | |
| 40 | 44 | ac.overlays.palettePending = nil | |
| 41 | 45 | ac.overlays.paletteMu.Unlock() | |
| 42 | 46 | d.paint(sess, ac, true) | |
| 43 | 47 | } | |
| 44 | 48 | ||
| 45 | - | func (d *Daemon) closePalette(ac *attachedClient) { | |
| 46 | - | ac.overlays.paletteMu.Lock() | |
| 47 | - | ac.overlays.palette = nil | |
| 48 | - | ac.overlays.paletteRecent = nil | |
| 49 | - | ac.overlays.palettePending = nil | |
| 50 | - | ac.overlays.paletteMu.Unlock() | |
| 49 | + | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | |
| 50 | + | names := make([]string, len(recent)) | |
| 51 | + | for i, entry := range recent { | |
| 52 | + | names[i] = entry.name | |
| 53 | + | } | |
| 54 | + | return palette.BuildRecentSessionHints(names, args) | |
| 51 | 55 | } | |
| 52 | 56 | ||
| 53 | 57 | func (d *Daemon) paletteCommands() []command.Command { | |
| @@ -100,6 +104,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 100 | 104 | if sess == nil { | |
| 101 | 105 | return | |
| 102 | 106 | } | |
| 107 | + | var cmd command.Command | |
| 108 | + | var args []string | |
| 109 | + | var recent []recentSession | |
| 110 | + | var generation uint64 | |
| 111 | + | var rawQuery string | |
| 112 | + | changed, cancel, execute := false, false, false | |
| 103 | 113 | ||
| 104 | 114 | ac.overlays.paletteMu.Lock() | |
| 105 | 115 | if ac.overlays.palette == nil { | |
| @@ -107,82 +117,118 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 107 | 117 | ac.overlays.paletteMu.Unlock() | |
| 108 | 118 | return | |
| 109 | 119 | } | |
| 110 | - | changed := false | |
| 111 | - | exit := false | |
| 112 | - | run := false | |
| 113 | - | var cmd command.Command | |
| 114 | - | var recent []recentSession | |
| 115 | - | var ok bool | |
| 116 | - | ||
| 117 | 120 | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | |
| 118 | 121 | rune: func(r rune) { | |
| 119 | 122 | ac.overlays.palette.Insert(r) | |
| 123 | + | ac.overlays.paletteFeedback = "" | |
| 124 | + | ac.overlays.palette.SetFeedback("") | |
| 120 | 125 | changed = true | |
| 121 | 126 | }, | |
| 122 | 127 | backspace: func() { | |
| 123 | 128 | ac.overlays.palette.Backspace() | |
| 129 | + | ac.overlays.paletteFeedback = "" | |
| 130 | + | ac.overlays.palette.SetFeedback("") | |
| 124 | 131 | changed = true | |
| 125 | 132 | }, | |
| 133 | + | up: func() { ac.overlays.palette.Up(); changed = true }, | |
| 134 | + | down: func() { ac.overlays.palette.Down(); changed = true }, | |
| 135 | + | cancel: func() { cancel = true }, | |
| 126 | 136 | enter: func() { | |
| 127 | - | cmd, ok = ac.overlays.palette.Selected() | |
| 128 | - | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |
| 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 | - | } | |
| 137 | - | run = true | |
| 138 | - | exit = true | |
| 137 | + | cmd, _ = ac.overlays.palette.Selected() | |
| 138 | + | action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()) | |
| 139 | + | if !valid { | |
| 140 | + | ac.overlays.paletteFeedback = "invalid command arguments" | |
| 141 | + | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | |
| 142 | + | changed = true | |
| 143 | + | return | |
| 139 | 144 | } | |
| 140 | - | }, | |
| 141 | - | cancel: func() { exit = true }, | |
| 142 | - | up: func() { | |
| 143 | - | ac.overlays.palette.Up() | |
| 144 | - | changed = true | |
| 145 | - | }, | |
| 146 | - | down: func() { | |
| 147 | - | ac.overlays.palette.Down() | |
| 148 | - | changed = true | |
| 145 | + | args = action.Args | |
| 146 | + | rawQuery = ac.overlays.palette.Query() | |
| 147 | + | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |
| 148 | + | generation = ac.overlays.paletteGeneration | |
| 149 | + | execute = true | |
| 149 | 150 | }, | |
| 150 | 151 | }) | |
| 151 | - | if exit { | |
| 152 | - | ac.overlays.palette = nil | |
| 153 | - | ac.overlays.palettePending = nil | |
| 152 | + | if changed { | |
| 153 | + | ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), cmd)) | |
| 154 | + | } | |
| 155 | + | if cancel { | |
| 156 | + | d.clearPaletteLocked(ac) | |
| 154 | 157 | } | |
| 155 | 158 | ac.overlays.paletteMu.Unlock() | |
| 159 | + | if cancel { | |
| 160 | + | d.paint(sess, ac, true) | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | if !execute { | |
| 164 | + | if changed { | |
| 165 | + | d.paint(sess, ac, true) | |
| 166 | + | } | |
| 167 | + | return | |
| 168 | + | } | |
| 156 | 169 | ||
| 157 | - | if run && ok { | |
| 158 | - | action := palette.Action{Command: cmd} | |
| 159 | - | if parsed, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()); valid { | |
| 160 | - | action = parsed | |
| 170 | + | // Revalidate live target without paletteMu. A captured ID is never replaced | |
| 171 | + | // by a newly-ranked session, so MRU changes cannot shift a requested rank. | |
| 172 | + | if cmd.Code == "JRS" { | |
| 173 | + | rank, err := command.ParsePositiveDecimal(args) | |
| 174 | + | if err != nil || rank > len(recent) || d.sessionByID(recent[rank-1].id) == nil { | |
| 175 | + | d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable") | |
| 176 | + | d.paint(sess, ac, true) | |
| 177 | + | return | |
| 161 | 178 | } | |
| 162 | - | if err := action.Command.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, action.Args); err != nil { | |
| 163 | - | sess.mu.Lock() | |
| 164 | - | name := sess.name | |
| 165 | - | sess.mu.Unlock() | |
| 166 | - | d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code) | |
| 167 | - | } else { | |
| 168 | - | d.recordPaletteUse(cmd.Code) | |
| 179 | + | // Publication remains generation-safe; close only if this is still the | |
| 180 | + | // same exact palette interaction and raw argument token. | |
| 181 | + | if !d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 182 | + | return | |
| 169 | 183 | } | |
| 184 | + | d.switchToTarget(sess, ac, picker.Target{Session: recent[rank-1].id, TabIndex: -1}) | |
| 185 | + | d.recordPaletteUse(cmd.Code) | |
| 170 | 186 | return | |
| 171 | 187 | } | |
| 172 | - | if exit || changed { | |
| 173 | - | d.paint(sess, ac, true) | |
| 188 | + | if !d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 189 | + | return | |
| 190 | + | } | |
| 191 | + | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, args); err != nil { | |
| 192 | + | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | |
| 193 | + | } else { | |
| 194 | + | d.recordPaletteUse(cmd.Code) | |
| 174 | 195 | } | |
| 175 | 196 | } | |
| 176 | 197 | ||
| 177 | - | func (d *Daemon) validRecentSessionAction(recent []recentSession, args []string) bool { | |
| 178 | - | if len(args) != 1 { | |
| 179 | - | return false | |
| 198 | + | func paletteArgs(query string, cmd command.Command) []string { | |
| 199 | + | action, ok := palette.ParseAction([]command.Command{cmd}, query) | |
| 200 | + | if !ok { | |
| 201 | + | return nil | |
| 180 | 202 | } | |
| 181 | - | rank, err := strconv.Atoi(args[0]) | |
| 182 | - | if err != nil || rank < 1 || rank > len(recent) { | |
| 203 | + | return action.Args | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (d *Daemon) clearPaletteLocked(ac *attachedClient) { | |
| 207 | + | ac.overlays.paletteGeneration++ | |
| 208 | + | ac.overlays.palette = nil | |
| 209 | + | ac.overlays.paletteRecent = nil | |
| 210 | + | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 211 | + | ac.overlays.paletteFeedback = "" | |
| 212 | + | ac.overlays.palettePending = nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery, feedback string) { | |
| 216 | + | ac.overlays.paletteMu.Lock() | |
| 217 | + | defer ac.overlays.paletteMu.Unlock() | |
| 218 | + | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | ac.overlays.paletteFeedback = feedback | |
| 222 | + | ac.overlays.palette.SetFeedback(feedback) | |
| 223 | + | } | |
| 224 | + | func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool { | |
| 225 | + | ac.overlays.paletteMu.Lock() | |
| 226 | + | defer ac.overlays.paletteMu.Unlock() | |
| 227 | + | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | |
| 183 | 228 | return false | |
| 184 | 229 | } | |
| 185 | - | return d.sessionByID(recent[rank-1].id) != nil | |
| 230 | + | d.clearPaletteLocked(ac) | |
| 231 | + | return true | |
| 186 | 232 | } | |
| 187 | 233 | ||
| 188 | 234 | type paletteExec struct { | |
| @@ -319,19 +365,13 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 319 | 365 | return nil | |
| 320 | 366 | } | |
| 321 | 367 | ||
| 322 | - | func (e paletteExec) JumpRecentSession(args []string) error { | |
| 323 | - | if len(args) != 1 { | |
| 324 | - | return nil | |
| 368 | + | func (e paletteExec) JumpRecentSession(rank int) error { | |
| 369 | + | if rank < 1 || rank > len(e.recent) { | |
| 370 | + | return command.ErrInvalidArguments | |
| 325 | 371 | } | |
| 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 | 372 | target := e.recent[rank-1] | |
| 333 | 373 | if e.d.sessionByID(target.id) == nil { | |
| 334 | - | return nil | |
| 374 | + | return command.ErrInvalidArguments | |
| 335 | 375 | } | |
| 336 | 376 | e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | |
| 337 | 377 | return nil | |
| @@ -1,8 +1,6 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "strconv" | |||
| 5 | - | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 4 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/usecase/command" | 5 | "github.com/bnema/vev/internal/usecase/command" | ||
| 8 | "github.com/bnema/vev/internal/usecase/layout" | 6 | "github.com/bnema/vev/internal/usecase/layout" | ||
| @@ -33,21 +31,27 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal { | |||||
| 33 | } | 31 | } | ||
| 34 | 32 | ||||
| 35 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | 33 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | ||
| 36 | - | d.closePalette(ac) | 34 | + | // Capture daemon/session state before taking paletteMu: lock ordering forbids |
| 35 | + | // holding an overlay lock while inspecting live sessions. | |||
| 36 | + | recent := d.recentSessions(sess) | |||
| 37 | + | commands := d.paletteCommands() | |||
| 37 | ac.overlays.paletteMu.Lock() | 38 | ac.overlays.paletteMu.Lock() | ||
| 38 | - | ac.overlays.palette = palette.New(d.paletteCommands()) | 39 | + | ac.overlays.paletteGeneration++ |
| 39 | - | ac.overlays.paletteRecent = d.recentSessions(sess) | 40 | + | ac.overlays.palette = palette.New(commands) |
| 41 | + | ac.overlays.paletteRecent = recent | |||
| 42 | + | ac.overlays.paletteHints = recentSessionHints(recent, nil) | |||
| 43 | + | ac.overlays.paletteFeedback = "" | |||
| 40 | ac.overlays.palettePending = nil | 44 | ac.overlays.palettePending = nil | ||
| 41 | ac.overlays.paletteMu.Unlock() | 45 | ac.overlays.paletteMu.Unlock() | ||
| 42 | d.paint(sess, ac, true) | 46 | d.paint(sess, ac, true) | ||
| 43 | } | 47 | } | ||
| 44 | 48 | ||||
| 45 | - | func (d *Daemon) closePalette(ac *attachedClient) { | 49 | + | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { |
| 46 | - | ac.overlays.paletteMu.Lock() | 50 | + | names := make([]string, len(recent)) |
| 47 | - | ac.overlays.palette = nil | 51 | + | for i, entry := range recent { |
| 48 | - | ac.overlays.paletteRecent = nil | 52 | + | names[i] = entry.name |
| 49 | - | ac.overlays.palettePending = nil | 53 | + | } |
| 50 | - | ac.overlays.paletteMu.Unlock() | 54 | + | return palette.BuildRecentSessionHints(names, args) |
| 51 | } | 55 | } | ||
| 52 | 56 | ||||
| 53 | func (d *Daemon) paletteCommands() []command.Command { | 57 | func (d *Daemon) paletteCommands() []command.Command { | ||
| @@ -100,6 +104,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 100 | if sess == nil { | 104 | if sess == nil { | ||
| 101 | return | 105 | return | ||
| 102 | } | 106 | } | ||
| 107 | + | var cmd command.Command | |||
| 108 | + | var args []string | |||
| 109 | + | var recent []recentSession | |||
| 110 | + | var generation uint64 | |||
| 111 | + | var rawQuery string | |||
| 112 | + | changed, cancel, execute := false, false, false | |||
| 103 | 113 | ||||
| 104 | ac.overlays.paletteMu.Lock() | 114 | ac.overlays.paletteMu.Lock() | ||
| 105 | if ac.overlays.palette == nil { | 115 | if ac.overlays.palette == nil { | ||
| @@ -107,82 +117,118 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 107 | ac.overlays.paletteMu.Unlock() | 117 | ac.overlays.paletteMu.Unlock() | ||
| 108 | return | 118 | return | ||
| 109 | } | 119 | } | ||
| 110 | - | changed := false | |||
| 111 | - | exit := false | |||
| 112 | - | run := false | |||
| 113 | - | var cmd command.Command | |||
| 114 | - | var recent []recentSession | |||
| 115 | - | var ok bool | |||
| 116 | - | ||||
| 117 | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | 120 | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | ||
| 118 | rune: func(r rune) { | 121 | rune: func(r rune) { | ||
| 119 | ac.overlays.palette.Insert(r) | 122 | ac.overlays.palette.Insert(r) | ||
| 123 | + | ac.overlays.paletteFeedback = "" | |||
| 124 | + | ac.overlays.palette.SetFeedback("") | |||
| 120 | changed = true | 125 | changed = true | ||
| 121 | }, | 126 | }, | ||
| 122 | backspace: func() { | 127 | backspace: func() { | ||
| 123 | ac.overlays.palette.Backspace() | 128 | ac.overlays.palette.Backspace() | ||
| 129 | + | ac.overlays.paletteFeedback = "" | |||
| 130 | + | ac.overlays.palette.SetFeedback("") | |||
| 124 | changed = true | 131 | changed = true | ||
| 125 | }, | 132 | }, | ||
| 133 | + | up: func() { ac.overlays.palette.Up(); changed = true }, | |||
| 134 | + | down: func() { ac.overlays.palette.Down(); changed = true }, | |||
| 135 | + | cancel: func() { cancel = true }, | |||
| 126 | enter: func() { | 136 | enter: func() { | ||
| 127 | - | cmd, ok = ac.overlays.palette.Selected() | 137 | + | cmd, _ = ac.overlays.palette.Selected() |
| 128 | - | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | 138 | + | action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()) |
| 129 | - | if ok { | 139 | + | if !valid { |
| 130 | - | if cmd.Arguments == command.ArgumentsRequired { | 140 | + | ac.overlays.paletteFeedback = "invalid command arguments" |
| 131 | - | action, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()) | 141 | + | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) |
| 132 | - | if !valid || (cmd.Code == "JRS" && !d.validRecentSessionAction(recent, action.Args)) { | 142 | + | changed = true |
| 133 | - | ok = false | 143 | + | return |
| 134 | - | return | |||
| 135 | - | } | |||
| 136 | - | } | |||
| 137 | - | run = true | |||
| 138 | - | exit = true | |||
| 139 | } | 144 | } | ||
| 140 | - | }, | 145 | + | args = action.Args |
| 141 | - | cancel: func() { exit = true }, | 146 | + | rawQuery = ac.overlays.palette.Query() |
| 142 | - | up: func() { | 147 | + | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) |
| 143 | - | ac.overlays.palette.Up() | 148 | + | generation = ac.overlays.paletteGeneration |
| 144 | - | changed = true | 149 | + | execute = true |
| 145 | - | }, | |||
| 146 | - | down: func() { | |||
| 147 | - | ac.overlays.palette.Down() | |||
| 148 | - | changed = true | |||
| 149 | }, | 150 | }, | ||
| 150 | }) | 151 | }) | ||
| 151 | - | if exit { | 152 | + | if changed { |
| 152 | - | ac.overlays.palette = nil | 153 | + | ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), cmd)) |
| 153 | - | ac.overlays.palettePending = nil | 154 | + | } |
| 155 | + | if cancel { | |||
| 156 | + | d.clearPaletteLocked(ac) | |||
| 154 | } | 157 | } | ||
| 155 | ac.overlays.paletteMu.Unlock() | 158 | ac.overlays.paletteMu.Unlock() | ||
| 159 | + | if cancel { | |||
| 160 | + | d.paint(sess, ac, true) | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | if !execute { | |||
| 164 | + | if changed { | |||
| 165 | + | d.paint(sess, ac, true) | |||
| 166 | + | } | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 156 | 169 | ||||
| 157 | - | if run && ok { | 170 | + | // Revalidate live target without paletteMu. A captured ID is never replaced |
| 158 | - | action := palette.Action{Command: cmd} | 171 | + | // by a newly-ranked session, so MRU changes cannot shift a requested rank. |
| 159 | - | if parsed, valid := palette.ParseAction([]command.Command{cmd}, ac.overlays.palette.Query()); valid { | 172 | + | if cmd.Code == "JRS" { |
| 160 | - | action = parsed | 173 | + | rank, err := command.ParsePositiveDecimal(args) |
| 174 | + | if err != nil || rank > len(recent) || d.sessionByID(recent[rank-1].id) == nil { | |||
| 175 | + | d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable") | |||
| 176 | + | d.paint(sess, ac, true) | |||
| 177 | + | return | |||
| 161 | } | 178 | } | ||
| 162 | - | if err := action.Command.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, action.Args); err != nil { | 179 | + | // Publication remains generation-safe; close only if this is still the |
| 163 | - | sess.mu.Lock() | 180 | + | // same exact palette interaction and raw argument token. |
| 164 | - | name := sess.name | 181 | + | if !d.closeExecutedPalette(ac, generation, rawQuery) { |
| 165 | - | sess.mu.Unlock() | 182 | + | return |
| 166 | - | d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code) | |||
| 167 | - | } else { | |||
| 168 | - | d.recordPaletteUse(cmd.Code) | |||
| 169 | } | 183 | } | ||
| 184 | + | d.switchToTarget(sess, ac, picker.Target{Session: recent[rank-1].id, TabIndex: -1}) | |||
| 185 | + | d.recordPaletteUse(cmd.Code) | |||
| 170 | return | 186 | return | ||
| 171 | } | 187 | } | ||
| 172 | - | if exit || changed { | 188 | + | if !d.closeExecutedPalette(ac, generation, rawQuery) { |
| 173 | - | d.paint(sess, ac, true) | 189 | + | return |
| 190 | + | } | |||
| 191 | + | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, args); err != nil { | |||
| 192 | + | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | |||
| 193 | + | } else { | |||
| 194 | + | d.recordPaletteUse(cmd.Code) | |||
| 174 | } | 195 | } | ||
| 175 | } | 196 | } | ||
| 176 | 197 | ||||
| 177 | - | func (d *Daemon) validRecentSessionAction(recent []recentSession, args []string) bool { | 198 | + | func paletteArgs(query string, cmd command.Command) []string { |
| 178 | - | if len(args) != 1 { | 199 | + | action, ok := palette.ParseAction([]command.Command{cmd}, query) |
| 179 | - | return false | 200 | + | if !ok { |
| 201 | + | return nil | |||
| 180 | } | 202 | } | ||
| 181 | - | rank, err := strconv.Atoi(args[0]) | 203 | + | return action.Args |
| 182 | - | if err != nil || rank < 1 || rank > len(recent) { | 204 | + | } |
| 205 | + | ||||
| 206 | + | func (d *Daemon) clearPaletteLocked(ac *attachedClient) { | |||
| 207 | + | ac.overlays.paletteGeneration++ | |||
| 208 | + | ac.overlays.palette = nil | |||
| 209 | + | ac.overlays.paletteRecent = nil | |||
| 210 | + | ac.overlays.paletteHints = palette.ContextualHints{} | |||
| 211 | + | ac.overlays.paletteFeedback = "" | |||
| 212 | + | ac.overlays.palettePending = nil | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery, feedback string) { | |||
| 216 | + | ac.overlays.paletteMu.Lock() | |||
| 217 | + | defer ac.overlays.paletteMu.Unlock() | |||
| 218 | + | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | ac.overlays.paletteFeedback = feedback | |||
| 222 | + | ac.overlays.palette.SetFeedback(feedback) | |||
| 223 | + | } | |||
| 224 | + | func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool { | |||
| 225 | + | ac.overlays.paletteMu.Lock() | |||
| 226 | + | defer ac.overlays.paletteMu.Unlock() | |||
| 227 | + | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | |||
| 183 | return false | 228 | return false | ||
| 184 | } | 229 | } | ||
| 185 | - | return d.sessionByID(recent[rank-1].id) != nil | 230 | + | d.clearPaletteLocked(ac) |
| 231 | + | return true | |||
| 186 | } | 232 | } | ||
| 187 | 233 | ||||
| 188 | type paletteExec struct { | 234 | type paletteExec struct { | ||
| @@ -319,19 +365,13 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 319 | return nil | 365 | return nil | ||
| 320 | } | 366 | } | ||
| 321 | 367 | ||||
| 322 | - | func (e paletteExec) JumpRecentSession(args []string) error { | 368 | + | func (e paletteExec) JumpRecentSession(rank int) error { |
| 323 | - | if len(args) != 1 { | 369 | + | if rank < 1 || rank > len(e.recent) { |
| 324 | - | return nil | 370 | + | return command.ErrInvalidArguments |
| 325 | } | 371 | } | ||
| 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] | 372 | target := e.recent[rank-1] | ||
| 333 | if e.d.sessionByID(target.id) == nil { | 373 | if e.d.sessionByID(target.id) == nil { | ||
| 334 | - | return nil | 374 | + | return command.ErrInvalidArguments |
| 335 | } | 375 | } | ||
| 336 | e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | 376 | e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | ||
| 337 | return nil | 377 | return nil | ||
A internal/usecase/palette/contextual_hints.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package palette | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // RecentSessionHint is the immutable display data for one captured MRU rank. | |
| 10 | + | type RecentSessionHint struct { | |
| 11 | + | Rank int | |
| 12 | + | Name string | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // ContextualHints is immutable contextual palette guidance. | |
| 16 | + | type ContextualHints struct { | |
| 17 | + | Kind command.ContextHint | |
| 18 | + | Recent []RecentSessionHint | |
| 19 | + | Feedback string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // BuildRecentSessionHints produces guidance without consulting live daemon state. | |
| 23 | + | func BuildRecentSessionHints(names []string, args []string) ContextualHints { | |
| 24 | + | h := ContextualHints{Kind: command.ContextHintRecentSessions} | |
| 25 | + | h.Recent = make([]RecentSessionHint, len(names)) | |
| 26 | + | for i, name := range names { | |
| 27 | + | h.Recent[i] = RecentSessionHint{Rank: i + 1, Name: name} | |
| 28 | + | } | |
| 29 | + | if len(names) == 0 { | |
| 30 | + | h.Feedback = "no recent sessions" | |
| 31 | + | return h | |
| 32 | + | } | |
| 33 | + | rank, err := command.ParsePositiveDecimal(args) | |
| 34 | + | if len(args) == 0 { | |
| 35 | + | h.Feedback = "enter a recent-session rank" | |
| 36 | + | } else if err != nil { | |
| 37 | + | h.Feedback = "rank must be one positive decimal" | |
| 38 | + | } else if rank > len(names) { | |
| 39 | + | h.Feedback = fmt.Sprintf("rank %d is unavailable", rank) | |
| 40 | + | } else { | |
| 41 | + | h.Feedback = fmt.Sprintf("jump to recent session %d", rank) | |
| 42 | + | } | |
| 43 | + | return h | |
| 44 | + | } | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package palette | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // RecentSessionHint is the immutable display data for one captured MRU rank. | |||
| 10 | + | type RecentSessionHint struct { | |||
| 11 | + | Rank int | |||
| 12 | + | Name string | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // ContextualHints is immutable contextual palette guidance. | |||
| 16 | + | type ContextualHints struct { | |||
| 17 | + | Kind command.ContextHint | |||
| 18 | + | Recent []RecentSessionHint | |||
| 19 | + | Feedback string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // BuildRecentSessionHints produces guidance without consulting live daemon state. | |||
| 23 | + | func BuildRecentSessionHints(names []string, args []string) ContextualHints { | |||
| 24 | + | h := ContextualHints{Kind: command.ContextHintRecentSessions} | |||
| 25 | + | h.Recent = make([]RecentSessionHint, len(names)) | |||
| 26 | + | for i, name := range names { | |||
| 27 | + | h.Recent[i] = RecentSessionHint{Rank: i + 1, Name: name} | |||
| 28 | + | } | |||
| 29 | + | if len(names) == 0 { | |||
| 30 | + | h.Feedback = "no recent sessions" | |||
| 31 | + | return h | |||
| 32 | + | } | |||
| 33 | + | rank, err := command.ParsePositiveDecimal(args) | |||
| 34 | + | if len(args) == 0 { | |||
| 35 | + | h.Feedback = "enter a recent-session rank" | |||
| 36 | + | } else if err != nil { | |||
| 37 | + | h.Feedback = "rank must be one positive decimal" | |||
| 38 | + | } else if rank > len(names) { | |||
| 39 | + | h.Feedback = fmt.Sprintf("rank %d is unavailable", rank) | |||
| 40 | + | } else { | |||
| 41 | + | h.Feedback = fmt.Sprintf("jump to recent session %d", rank) | |||
| 42 | + | } | |||
| 43 | + | return h | |||
| 44 | + | } | |||
M internal/usecase/palette/model.go +17 -4
| @@ -18,6 +18,7 @@ type Model struct { | |||
| 18 | 18 | matches []Match | |
| 19 | 19 | selected int | |
| 20 | 20 | scroll int | |
| 21 | + | feedback string | |
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 24 | func New(commands []command.Command) *Model { | |
| @@ -52,6 +53,13 @@ func (m *Model) Backspace() { | |||
| 52 | 53 | m.refresh() | |
| 53 | 54 | } | |
| 54 | 55 | } | |
| 56 | + | ||
| 57 | + | // SetFeedback sets interaction-local guidance shown below the query. | |
| 58 | + | func (m *Model) SetFeedback(feedback string) { | |
| 59 | + | if m != nil { | |
| 60 | + | m.feedback = feedback | |
| 61 | + | } | |
| 62 | + | } | |
| 55 | 63 | func (m *Model) Query() string { | |
| 56 | 64 | if m == nil { | |
| 57 | 65 | return "" | |
| @@ -140,7 +148,12 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 140 | 148 | return frame | |
| 141 | 149 | } | |
| 142 | 150 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | |
| 143 | - | visible := frame.Height - 1 | |
| 151 | + | start := 1 | |
| 152 | + | if m.feedback != "" && frame.Height > 1 { | |
| 153 | + | ui.DrawText(frame, 0, 1, frame.Width, m.feedback, desc) | |
| 154 | + | start++ | |
| 155 | + | } | |
| 156 | + | visible := frame.Height - start | |
| 144 | 157 | if visible <= 0 { | |
| 145 | 158 | return frame | |
| 146 | 159 | } | |
| @@ -169,7 +182,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 169 | 182 | if idx == m.selected { | |
| 170 | 183 | style = selection | |
| 171 | 184 | } | |
| 172 | - | ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 185 | + | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 173 | 186 | x := 0 | |
| 174 | 187 | highlight := map[int]bool{} | |
| 175 | 188 | for _, p := range match.Positions { | |
| @@ -183,7 +196,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 183 | 196 | cellStyle.Bold = true | |
| 184 | 197 | } | |
| 185 | 198 | if x < frame.Width { | |
| 186 | - | frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle}) | |
| 199 | + | frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle}) | |
| 187 | 200 | } | |
| 188 | 201 | x++ | |
| 189 | 202 | } | |
| @@ -191,7 +204,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 191 | 204 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | |
| 192 | 205 | x++ | |
| 193 | 206 | } | |
| 194 | - | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | |
| 207 | + | ui.DrawText(frame, x, y+start, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | |
| 195 | 208 | } | |
| 196 | 209 | return frame | |
| 197 | 210 | } | |
| @@ -18,6 +18,7 @@ type Model struct { | |||||
| 18 | matches []Match | 18 | matches []Match | ||
| 19 | selected int | 19 | selected int | ||
| 20 | scroll int | 20 | scroll int | ||
| 21 | + | feedback string | |||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
| 23 | func New(commands []command.Command) *Model { | 24 | func New(commands []command.Command) *Model { | ||
| @@ -52,6 +53,13 @@ func (m *Model) Backspace() { | |||||
| 52 | m.refresh() | 53 | m.refresh() | ||
| 53 | } | 54 | } | ||
| 54 | } | 55 | } | ||
| 56 | + | ||||
| 57 | + | // SetFeedback sets interaction-local guidance shown below the query. | |||
| 58 | + | func (m *Model) SetFeedback(feedback string) { | |||
| 59 | + | if m != nil { | |||
| 60 | + | m.feedback = feedback | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 55 | func (m *Model) Query() string { | 63 | func (m *Model) Query() string { | ||
| 56 | if m == nil { | 64 | if m == nil { | ||
| 57 | return "" | 65 | return "" | ||
| @@ -140,7 +148,12 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||||
| 140 | return frame | 148 | return frame | ||
| 141 | } | 149 | } | ||
| 142 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | 150 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | ||
| 143 | - | visible := frame.Height - 1 | 151 | + | start := 1 |
| 152 | + | if m.feedback != "" && frame.Height > 1 { | |||
| 153 | + | ui.DrawText(frame, 0, 1, frame.Width, m.feedback, desc) | |||
| 154 | + | start++ | |||
| 155 | + | } | |||
| 156 | + | visible := frame.Height - start | |||
| 144 | if visible <= 0 { | 157 | if visible <= 0 { | ||
| 145 | return frame | 158 | return frame | ||
| 146 | } | 159 | } | ||
| @@ -169,7 +182,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||||
| 169 | if idx == m.selected { | 182 | if idx == m.selected { | ||
| 170 | style = selection | 183 | style = selection | ||
| 171 | } | 184 | } | ||
| 172 | - | ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | 185 | + | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) |
| 173 | x := 0 | 186 | x := 0 | ||
| 174 | highlight := map[int]bool{} | 187 | highlight := map[int]bool{} | ||
| 175 | for _, p := range match.Positions { | 188 | for _, p := range match.Positions { | ||
| @@ -183,7 +196,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||||
| 183 | cellStyle.Bold = true | 196 | cellStyle.Bold = true | ||
| 184 | } | 197 | } | ||
| 185 | if x < frame.Width { | 198 | if x < frame.Width { | ||
| 186 | - | frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle}) | 199 | + | frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle}) |
| 187 | } | 200 | } | ||
| 188 | x++ | 201 | x++ | ||
| 189 | } | 202 | } | ||
| @@ -191,7 +204,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||||
| 191 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | 204 | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | ||
| 192 | x++ | 205 | x++ | ||
| 193 | } | 206 | } | ||
| 194 | - | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | 207 | + | ui.DrawText(frame, x, y+start, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) |
| 195 | } | 208 | } | ||
| 196 | return frame | 209 | return frame | ||
| 197 | } | 210 | } | ||