refactor(session): share recent session action state
- commit
- 54b21769d6ea9c3a87db62c857ee95a2b3940de7
- author
- brice · · 2026-07-11 11:59 UTC
- parent
- b910459
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 25 | 25 | RenameSession() error | |
| 26 | 26 | RenameTab() error | |
| 27 | 27 | 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 | 47 | // Command describes an executable command. | |
| 31 | 48 | type Command struct { | |
| 32 | 49 | Slug, Code, Name, Desc string | |
| 33 | - | Run func(Context) error | |
| 50 | + | Arguments Arguments | |
| 51 | + | ContextHint ContextHint | |
| 52 | + | Run func(Context, []string) error | |
| 34 | 53 | } | |
| @@ -25,10 +25,29 @@ type Context interface { | |||||
| 25 | RenameSession() error | 25 | RenameSession() error | ||
| 26 | RenameTab() error | 26 | RenameTab() error | ||
| 27 | OpenSessionPicker() error | 27 | 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 string | 49 | Slug, Code, Name, Desc string | ||
| 33 | - | Run func(Context) error | 50 | + | 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 | |||
| 519 | 519 | 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 | 573 | // NextTab provides a mock function for the type MockContext | |
| 523 | 574 | func (_mock *MockContext) NextTab() error { | |
| 524 | 575 | ret := _mock.Called() | |
| @@ -519,6 +519,57 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont | |||||
| 519 | return _c | 519 | 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 MockContext | 573 | // 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" | |||
| 5 | 5 | // Registry returns all commands in display order. | |
| 6 | 6 | func Registry() []Command { | |
| 7 | 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() }}, | |
| 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() }}, | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | 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 commands | 34 | return commands | ||
M internal/usecase/command/registry_test.go +3 -2
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | 9 | commands := Registry() | |
| 10 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "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 | 12 | if len(commands) != len(wantCodes) { | |
| 13 | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -86,6 +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 | 90 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | |
| 90 | 91 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | |
| 91 | 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 | 104 | ctx := NewMockContext(t) | |
| 104 | 105 | tt.expect(ctx) | |
| 105 | - | if err := cmd.Run(ctx); err != nil { | |
| 106 | + | if err := cmd.Run(ctx, nil); err != nil { | |
| 106 | 107 | t.Fatalf("Run() error = %v, want nil", err) | |
| 107 | 108 | } | |
| 108 | 109 | }) | |
| @@ -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 { | |||
| 21 | 21 | ||
| 22 | 22 | paletteMu sync.Mutex | |
| 23 | 23 | palette *palette.Model | |
| 24 | + | paletteRecent []recentSession | |
| 24 | 25 | palettePending []byte | |
| 25 | 26 | ||
| 26 | 27 | promptMu sync.Mutex | |
| @@ -21,6 +21,7 @@ type overlayRuntime struct { | |||||
| 21 | 21 | ||||
| 22 | paletteMu sync.Mutex | 22 | paletteMu sync.Mutex | ||
| 23 | palette *palette.Model | 23 | palette *palette.Model | ||
| 24 | + | paletteRecent []recentSession | |||
| 24 | palettePending []byte | 25 | palettePending []byte | ||
| 25 | 26 | ||||
| 26 | promptMu sync.Mutex | 27 | promptMu sync.Mutex | ||
M internal/usecase/daemon/palette.go +52 -4
| @@ -1,10 +1,13 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strconv" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain" | |
| 5 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | 9 | "github.com/bnema/vev/internal/usecase/palette" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 8 | 11 | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | 12 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 13 | ) | |
| @@ -33,6 +36,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 33 | 36 | d.closePalette(ac) | |
| 34 | 37 | ac.overlays.paletteMu.Lock() | |
| 35 | 38 | ac.overlays.palette = palette.New(d.paletteCommands()) | |
| 39 | + | ac.overlays.paletteRecent = d.recentSessions(sess) | |
| 36 | 40 | ac.overlays.palettePending = nil | |
| 37 | 41 | ac.overlays.paletteMu.Unlock() | |
| 38 | 42 | d.paint(sess, ac, true) | |
| @@ -41,6 +45,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 41 | 45 | func (d *Daemon) closePalette(ac *attachedClient) { | |
| 42 | 46 | ac.overlays.paletteMu.Lock() | |
| 43 | 47 | ac.overlays.palette = nil | |
| 48 | + | ac.overlays.paletteRecent = nil | |
| 44 | 49 | ac.overlays.palettePending = nil | |
| 45 | 50 | ac.overlays.paletteMu.Unlock() | |
| 46 | 51 | } | |
| @@ -106,6 +111,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 106 | 111 | exit := false | |
| 107 | 112 | run := false | |
| 108 | 113 | var cmd command.Command | |
| 114 | + | var recent []recentSession | |
| 109 | 115 | var ok bool | |
| 110 | 116 | ||
| 111 | 117 | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | |
| @@ -119,7 +125,15 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 119 | 125 | }, | |
| 120 | 126 | enter: func() { | |
| 121 | 127 | cmd, ok = ac.overlays.palette.Selected() | |
| 128 | + | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |
| 122 | 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 | 137 | run = true | |
| 124 | 138 | exit = true | |
| 125 | 139 | } | |
| @@ -141,7 +155,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 141 | 155 | ac.overlays.paletteMu.Unlock() | |
| 142 | 156 | ||
| 143 | 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 | 163 | sess.mu.Lock() | |
| 146 | 164 | name := sess.name | |
| 147 | 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 | 188 | type paletteExec struct { | |
| 160 | - | d *Daemon | |
| 161 | - | sess *session | |
| 162 | - | ac *attachedClient | |
| 189 | + | d *Daemon | |
| 190 | + | sess *session | |
| 191 | + | ac *attachedClient | |
| 192 | + | recent []recentSession | |
| 163 | 193 | } | |
| 164 | 194 | ||
| 165 | 195 | func (e paletteExec) CreateTab() error { | |
| @@ -289,6 +319,24 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 289 | 319 | 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 | 340 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 293 | 341 | styleSet := resolveThemeStyles(styles) | |
| 294 | 342 | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg) | |
| @@ -1,10 +1,13 @@ | |||||
| 1 | package daemon | 1 | 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 = nil | 40 | 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 = nil | 47 | ac.overlays.palette = nil | ||
| 48 | + | ac.overlays.paletteRecent = nil | |||
| 44 | ac.overlays.palettePending = nil | 49 | 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 := false | 111 | exit := false | ||
| 107 | run := false | 112 | run := false | ||
| 108 | var cmd command.Command | 113 | var cmd command.Command | ||
| 114 | + | var recent []recentSession | |||
| 109 | var ok bool | 115 | 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 = true | 137 | run = true | ||
| 124 | exit = true | 138 | 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.name | 164 | 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 *Daemon | 189 | + | d *Daemon |
| 161 | - | sess *session | 190 | + | sess *session |
| 162 | - | ac *attachedClient | 191 | + | 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 nil | 319 | 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 ( | |||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/palette" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/ui" | |
| 16 | 17 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -138,7 +139,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 138 | 139 | for i, cmd := range commands { | |
| 139 | 140 | 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 | 145 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| @@ -159,7 +160,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |||
| 159 | 160 | wg.Wait() | |
| 160 | 161 | ||
| 161 | 162 | commands := d.paletteCommands() | |
| 162 | - | require.Len(t, commands, len(codes)) | |
| 163 | + | require.Len(t, commands, len(command.Registry())) | |
| 163 | 164 | seen := map[string]bool{} | |
| 164 | 165 | for _, cmd := range commands { | |
| 165 | 166 | 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.Code | 140 | 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 ( | |||
| 29 | 29 | "strconv" | |
| 30 | 30 | "time" | |
| 31 | 31 | ||
| 32 | - | "github.com/bnema/vev/internal/domain" | |
| 33 | 32 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 34 | 33 | "github.com/bnema/vev/internal/usecase/ui" | |
| 35 | 34 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -141,7 +140,7 @@ type barState struct { | |||
| 141 | 140 | topRight string | |
| 142 | 141 | bottomRight string | |
| 143 | 142 | copyFeedback string | |
| 144 | - | mru []mruSession | |
| 143 | + | mru []recentSession | |
| 145 | 144 | attentionFrame int | |
| 146 | 145 | // theme is the client's terminal theme, if reported. Its zero value | |
| 147 | 146 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | |
| @@ -149,15 +148,6 @@ type barState struct { | |||
| 149 | 148 | 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 | 151 | type statusSnapshot struct { | |
| 162 | 152 | session string | |
| 163 | 153 | tabs []statusTab | |
| @@ -216,35 +206,7 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||
| 216 | 206 | if d == nil { | |
| 217 | 207 | return state | |
| 218 | 208 | } | |
| 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) | |
| 248 | 210 | return state | |
| 249 | 211 | } | |
| 250 | 212 | ||
| @@ -310,7 +272,7 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||
| 310 | 272 | 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 | 276 | // With no feedback, keep one blank trailing cell; with feedback, reserve | |
| 315 | 277 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | |
| 316 | 278 | copyReserve := 1 | |
| @@ -321,7 +283,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe | |||
| 321 | 283 | if physicalBudget <= 0 || len(entries) == 0 { | |
| 322 | 284 | return nil | |
| 323 | 285 | } | |
| 324 | - | cost := func(e mruSession) int { | |
| 286 | + | cost := func(e recentSession) int { | |
| 325 | 287 | name := e.name | |
| 326 | 288 | if e.ephemeral { | |
| 327 | 289 | 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 string | 140 | topRight string | ||
| 142 | bottomRight string | 141 | bottomRight string | ||
| 143 | copyFeedback string | 142 | copyFeedback string | ||
| 144 | - | mru []mruSession | 143 | + | mru []recentSession |
| 145 | attentionFrame int | 144 | attentionFrame int | ||
| 146 | // theme is the client's terminal theme, if reported. Its zero value | 145 | // theme is the client's terminal theme, if reported. Its zero value | ||
| 147 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | 146 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | ||
| @@ -149,15 +148,6 @@ type barState struct { | |||||
| 149 | theme themeui.Theme | 148 | 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 string | 152 | session string | ||
| 163 | tabs []statusTab | 153 | 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 state | 207 | 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 state | 210 | return state | ||
| 249 | } | 211 | } | ||
| 250 | 212 | ||||
| @@ -310,7 +272,7 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||||
| 310 | return labels | 272 | 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, reserve | 276 | // 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 := 1 | 278 | 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 nil | 284 | return nil | ||
| 323 | } | 285 | } | ||
| 324 | - | cost := func(e mruSession) int { | 286 | + | cost := func(e recentSession) int { |
| 325 | name := e.name | 287 | 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) { | |||
| 828 | 828 | state := barState{ | |
| 829 | 829 | status: statusSnapshot{session: "cur"}, | |
| 830 | 830 | attentionFrame: 1, | |
| 831 | - | mru: []mruSession{ | |
| 831 | + | mru: []recentSession{ | |
| 832 | 832 | {name: "fresh"}, | |
| 833 | 833 | {name: "tmp", ephemeral: true, attention: true}, | |
| 834 | 834 | }, | |
| @@ -863,7 +863,7 @@ func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | |||
| 863 | 863 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |
| 864 | 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 | 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 | 867 | row := make([]renderer.Cell, 16) | |
| 868 | 868 | ||
| 869 | 869 | drawStatusBarState(row, state, styles) | |
| @@ -888,7 +888,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |||
| 888 | 888 | } | |
| 889 | 889 | ||
| 890 | 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 | 892 | row := make([]renderer.Cell, 21) | |
| 893 | 893 | ||
| 894 | 894 | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| @@ -900,9 +900,9 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) { | |||
| 900 | 900 | } | |
| 901 | 901 | ||
| 902 | 902 | func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | |
| 903 | - | mru := make([]mruSession, 0, maxMRUSessions) | |
| 903 | + | mru := make([]recentSession, 0, maxMRUSessions) | |
| 904 | 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 | 907 | state := barState{status: statusSnapshot{session: "cur"}, mru: mru} | |
| 908 | 908 | ||
| @@ -962,27 +962,27 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 962 | 962 | { | |
| 963 | 963 | name: "script text and copy feedback", | |
| 964 | 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 | 966 | wantContains: []string{" a"}, | |
| 967 | 967 | wantSuffix: " main ↑3 * copied", | |
| 968 | 968 | }, | |
| 969 | 969 | { | |
| 970 | 970 | name: "hide on overlap", | |
| 971 | 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 | 973 | want: " cur ", | |
| 974 | 974 | wantNotContains: []string{"main", "copied"}, | |
| 975 | 975 | }, | |
| 976 | 976 | { | |
| 977 | 977 | name: "empty script keeps copy feedback behavior", | |
| 978 | 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 | 980 | want: " cur copied", | |
| 981 | 981 | }, | |
| 982 | 982 | { | |
| 983 | 983 | name: "mru whole entry fitting with script text", | |
| 984 | 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 | 986 | wantContains: []string{" fresh"}, | |
| 987 | 987 | wantNotContains: []string{"middle"}, | |
| 988 | 988 | wantSuffix: " git", | |
| @@ -990,7 +990,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 990 | 990 | { | |
| 991 | 991 | name: "mru fitting reserves wide right anchor width", | |
| 992 | 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 | 994 | wantNotContains: []string{" a "}, | |
| 995 | 995 | wantSuffix: " 界 界 ", | |
| 996 | 996 | continuationCells: []int{9, 11}, | |
| @@ -998,7 +998,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 998 | 998 | { | |
| 999 | 999 | name: "mru fitting counts wide session names", | |
| 1000 | 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 | 1002 | wantContains: []string{" 界 "}, | |
| 1003 | 1003 | wantNotContains: []string{" b "}, | |
| 1004 | 1004 | wantSuffix: " git", | |
| @@ -1031,7 +1031,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) { | |||
| 1031 | 1031 | } | |
| 1032 | 1032 | ||
| 1033 | 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 | 1035 | row := make([]renderer.Cell, 20) | |
| 1036 | 1036 | ||
| 1037 | 1037 | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| @@ -1044,7 +1044,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |||
| 1044 | 1044 | } | |
| 1045 | 1045 | ||
| 1046 | 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 | 1048 | tests := []struct { | |
| 1049 | 1049 | name string | |
| 1050 | 1050 | 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 string | 1049 | name string | ||
| 1050 | cols int | 1050 | 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 { | |||
| 88 | 88 | 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 | 109 | func (m *Model) clamp() { | |
| 93 | 110 | if len(m.matches) == 0 { | |
| 94 | 111 | m.selected = -1 | |
| @@ -88,7 +88,24 @@ func (m *Model) Matches() []Match { | |||||
| 88 | return out | 88 | 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 = -1 | 111 | m.selected = -1 | ||