feat(command): expose pane and tab moves
- commit
- 35e7583be560d909137ffa2a9d05d43d7ac617f3
- author
- brice · · 2026-07-28 11:18 UTC
- parent
- ac63f74
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +1538 -80
M internal/app/cmd_test.go +67 -0
| @@ -105,6 +105,73 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||
| 105 | 105 | } | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | + | func TestMoveCommandHelpUsesExactUsages(t *testing.T) { | |
| 109 | + | for _, tt := range []struct { | |
| 110 | + | slug, usage, desc string | |
| 111 | + | }{ | |
| 112 | + | {"move-pane", "move-pane <destination-session> <destination-tab-id>", "Move the focused pane to another live tab"}, | |
| 113 | + | {"move-tab", "move-tab <destination-session>", "Move the active tab to another live session"}, | |
| 114 | + | } { | |
| 115 | + | t.Run(tt.slug, func(t *testing.T) { | |
| 116 | + | got := cmdHelp(cmdInvocation{slug: tt.slug}) | |
| 117 | + | want := "usage: vev cmd [-s <session>] [--self] " + tt.usage + "\n\n" + tt.desc | |
| 118 | + | if got != want { | |
| 119 | + | t.Fatalf("cmdHelp() = %q, want %q", got, want) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |
| 126 | + | for _, tt := range []struct { | |
| 127 | + | slug string | |
| 128 | + | args []string | |
| 129 | + | }{ | |
| 130 | + | {slug: "move-pane", args: []string{"work", "t_dest"}}, | |
| 131 | + | {slug: "move-tab", args: []string{"work"}}, | |
| 132 | + | } { | |
| 133 | + | t.Run(tt.slug, func(t *testing.T) { | |
| 134 | + | parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...)) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |
| 137 | + | require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{ | |
| 138 | + | stdout: io.Discard, | |
| 139 | + | getenv: func(string) string { return "" }, | |
| 140 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 141 | + | })) | |
| 142 | + | require.Len(t, transport.sent, 1) | |
| 143 | + | request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |
| 144 | + | require.NoError(t, err) | |
| 145 | + | require.Equal(t, tt.slug, request.Slug) | |
| 146 | + | require.Equal(t, tt.args, request.Args) | |
| 147 | + | require.Equal(t, ports.ProtocolVersion, request.Version) | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |
| 153 | + | for _, invocation := range []cmdInvocation{ | |
| 154 | + | {slug: "move-pane"}, | |
| 155 | + | {slug: "move-pane", args: []string{"work", "t_dest", "extra"}}, | |
| 156 | + | {slug: "move-tab"}, | |
| 157 | + | {slug: "move-tab", args: []string{"work", "extra"}}, | |
| 158 | + | } { | |
| 159 | + | t.Run(invocation.slug+"/"+string(rune(len(invocation.args)+'0')), func(t *testing.T) { | |
| 160 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{ | |
| 161 | + | Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments", | |
| 162 | + | })}} | |
| 163 | + | err := runCmdWithDeps(context.Background(), invocation, cmdDeps{ | |
| 164 | + | stdout: io.Discard, | |
| 165 | + | getenv: func(string) string { return "" }, | |
| 166 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 167 | + | }) | |
| 168 | + | if ExitCode(err) != 2 { | |
| 169 | + | t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err)) | |
| 170 | + | } | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 108 | 175 | func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) { | |
| 109 | 176 | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | |
| 110 | 177 | t.Run(slug, func(t *testing.T) { | |
| @@ -105,6 +105,73 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||||
| 105 | } | 105 | } | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | func TestMoveCommandHelpUsesExactUsages(t *testing.T) { | |||
| 109 | + | for _, tt := range []struct { | |||
| 110 | + | slug, usage, desc string | |||
| 111 | + | }{ | |||
| 112 | + | {"move-pane", "move-pane <destination-session> <destination-tab-id>", "Move the focused pane to another live tab"}, | |||
| 113 | + | {"move-tab", "move-tab <destination-session>", "Move the active tab to another live session"}, | |||
| 114 | + | } { | |||
| 115 | + | t.Run(tt.slug, func(t *testing.T) { | |||
| 116 | + | got := cmdHelp(cmdInvocation{slug: tt.slug}) | |||
| 117 | + | want := "usage: vev cmd [-s <session>] [--self] " + tt.usage + "\n\n" + tt.desc | |||
| 118 | + | if got != want { | |||
| 119 | + | t.Fatalf("cmdHelp() = %q, want %q", got, want) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |||
| 126 | + | for _, tt := range []struct { | |||
| 127 | + | slug string | |||
| 128 | + | args []string | |||
| 129 | + | }{ | |||
| 130 | + | {slug: "move-pane", args: []string{"work", "t_dest"}}, | |||
| 131 | + | {slug: "move-tab", args: []string{"work"}}, | |||
| 132 | + | } { | |||
| 133 | + | t.Run(tt.slug, func(t *testing.T) { | |||
| 134 | + | parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...)) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |||
| 137 | + | require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{ | |||
| 138 | + | stdout: io.Discard, | |||
| 139 | + | getenv: func(string) string { return "" }, | |||
| 140 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |||
| 141 | + | })) | |||
| 142 | + | require.Len(t, transport.sent, 1) | |||
| 143 | + | request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |||
| 144 | + | require.NoError(t, err) | |||
| 145 | + | require.Equal(t, tt.slug, request.Slug) | |||
| 146 | + | require.Equal(t, tt.args, request.Args) | |||
| 147 | + | require.Equal(t, ports.ProtocolVersion, request.Version) | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |||
| 153 | + | for _, invocation := range []cmdInvocation{ | |||
| 154 | + | {slug: "move-pane"}, | |||
| 155 | + | {slug: "move-pane", args: []string{"work", "t_dest", "extra"}}, | |||
| 156 | + | {slug: "move-tab"}, | |||
| 157 | + | {slug: "move-tab", args: []string{"work", "extra"}}, | |||
| 158 | + | } { | |||
| 159 | + | t.Run(invocation.slug+"/"+string(rune(len(invocation.args)+'0')), func(t *testing.T) { | |||
| 160 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{ | |||
| 161 | + | Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments", | |||
| 162 | + | })}} | |||
| 163 | + | err := runCmdWithDeps(context.Background(), invocation, cmdDeps{ | |||
| 164 | + | stdout: io.Discard, | |||
| 165 | + | getenv: func(string) string { return "" }, | |||
| 166 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |||
| 167 | + | }) | |||
| 168 | + | if ExitCode(err) != 2 { | |||
| 169 | + | t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err)) | |||
| 170 | + | } | |||
| 171 | + | }) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 108 | func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) { | 175 | func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) { | ||
| 109 | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | 176 | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | ||
| 110 | t.Run(slug, func(t *testing.T) { | 177 | t.Run(slug, func(t *testing.T) { | ||
M internal/usecase/command/command.go +15 -0
| @@ -48,6 +48,8 @@ type Context interface { | |||
| 48 | 48 | ToggleStack() error | |
| 49 | 49 | ToggleFloatingPane() error | |
| 50 | 50 | ClosePane() error | |
| 51 | + | OpenMovePanePicker() error | |
| 52 | + | OpenMoveTabPicker() error | |
| 51 | 53 | FocusPaneLeft() error | |
| 52 | 54 | FocusPaneRight() error | |
| 53 | 55 | FocusPaneUp() error | |
| @@ -97,6 +99,16 @@ const ( | |||
| 97 | 99 | TargetPane | |
| 98 | 100 | ) | |
| 99 | 101 | ||
| 102 | + | // CommandScope declares the serialization boundary needed by a command. | |
| 103 | + | type CommandScope uint8 | |
| 104 | + | ||
| 105 | + | const ( | |
| 106 | + | CommandScopeSession CommandScope = iota | |
| 107 | + | // CommandScopeCrossSession requires dispatch to defer locking to the | |
| 108 | + | // mutation, which resolves and locks both source and destination sessions. | |
| 109 | + | CommandScopeCrossSession | |
| 110 | + | ) | |
| 111 | + | ||
| 100 | 112 | // ControlOptions carries per-invocation output preferences. | |
| 101 | 113 | type ControlOptions struct { | |
| 102 | 114 | JSON bool | |
| @@ -113,6 +125,8 @@ type ControlContext interface { | |||
| 113 | 125 | CreateSessionNamed(name string) error | |
| 114 | 126 | CloseTab() error | |
| 115 | 127 | ClosePane() error | |
| 128 | + | MovePane(destinationSession, destinationTabID string) error | |
| 129 | + | MoveTab(destinationSession string) error | |
| 116 | 130 | SplitRight() error | |
| 117 | 131 | SplitLeft() error | |
| 118 | 132 | SplitUp() error | |
| @@ -148,6 +162,7 @@ type Command struct { | |||
| 148 | 162 | Scriptable bool | |
| 149 | 163 | PaletteVisible bool | |
| 150 | 164 | Target TargetKind | |
| 165 | + | Scope CommandScope | |
| 151 | 166 | Run func(Context, []string) error | |
| 152 | 167 | Control func(ControlContext, []string, ControlOptions) (ControlResult, error) | |
| 153 | 168 | } | |
| @@ -48,6 +48,8 @@ type Context interface { | |||||
| 48 | ToggleStack() error | 48 | ToggleStack() error | ||
| 49 | ToggleFloatingPane() error | 49 | ToggleFloatingPane() error | ||
| 50 | ClosePane() error | 50 | ClosePane() error | ||
| 51 | + | OpenMovePanePicker() error | |||
| 52 | + | OpenMoveTabPicker() error | |||
| 51 | FocusPaneLeft() error | 53 | FocusPaneLeft() error | ||
| 52 | FocusPaneRight() error | 54 | FocusPaneRight() error | ||
| 53 | FocusPaneUp() error | 55 | FocusPaneUp() error | ||
| @@ -97,6 +99,16 @@ const ( | |||||
| 97 | TargetPane | 99 | TargetPane | ||
| 98 | ) | 100 | ) | ||
| 99 | 101 | ||||
| 102 | + | // CommandScope declares the serialization boundary needed by a command. | |||
| 103 | + | type CommandScope uint8 | |||
| 104 | + | ||||
| 105 | + | const ( | |||
| 106 | + | CommandScopeSession CommandScope = iota | |||
| 107 | + | // CommandScopeCrossSession requires dispatch to defer locking to the | |||
| 108 | + | // mutation, which resolves and locks both source and destination sessions. | |||
| 109 | + | CommandScopeCrossSession | |||
| 110 | + | ) | |||
| 111 | + | ||||
| 100 | // ControlOptions carries per-invocation output preferences. | 112 | // ControlOptions carries per-invocation output preferences. | ||
| 101 | type ControlOptions struct { | 113 | type ControlOptions struct { | ||
| 102 | JSON bool | 114 | JSON bool | ||
| @@ -113,6 +125,8 @@ type ControlContext interface { | |||||
| 113 | CreateSessionNamed(name string) error | 125 | CreateSessionNamed(name string) error | ||
| 114 | CloseTab() error | 126 | CloseTab() error | ||
| 115 | ClosePane() error | 127 | ClosePane() error | ||
| 128 | + | MovePane(destinationSession, destinationTabID string) error | |||
| 129 | + | MoveTab(destinationSession string) error | |||
| 116 | SplitRight() error | 130 | SplitRight() error | ||
| 117 | SplitLeft() error | 131 | SplitLeft() error | ||
| 118 | SplitUp() error | 132 | SplitUp() error | ||
| @@ -148,6 +162,7 @@ type Command struct { | |||||
| 148 | Scriptable bool | 162 | Scriptable bool | ||
| 149 | PaletteVisible bool | 163 | PaletteVisible bool | ||
| 150 | Target TargetKind | 164 | Target TargetKind | ||
| 165 | + | Scope CommandScope | |||
| 151 | Run func(Context, []string) error | 166 | Run func(Context, []string) error | ||
| 152 | Control func(ControlContext, []string, ControlOptions) (ControlResult, error) | 167 | Control func(ControlContext, []string, ControlOptions) (ControlResult, error) | ||
| 153 | } | 168 | } | ||
M internal/usecase/command/context_mock_test.go +88 -0
| @@ -790,6 +790,94 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_ | |||
| 790 | 790 | return _c | |
| 791 | 791 | } | |
| 792 | 792 | ||
| 793 | + | // OpenMovePanePicker provides a mock function for the type MockContext | |
| 794 | + | func (_mock *MockContext) OpenMovePanePicker() error { | |
| 795 | + | ret := _mock.Called() | |
| 796 | + | ||
| 797 | + | if len(ret) == 0 { | |
| 798 | + | panic("no return value specified for OpenMovePanePicker") | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | var r0 error | |
| 802 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 803 | + | r0 = returnFunc() | |
| 804 | + | } else { | |
| 805 | + | r0 = ret.Error(0) | |
| 806 | + | } | |
| 807 | + | return r0 | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | // MockContext_OpenMovePanePicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenMovePanePicker' | |
| 811 | + | type MockContext_OpenMovePanePicker_Call struct { | |
| 812 | + | *mock.Call | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | // OpenMovePanePicker is a helper method to define mock.On call | |
| 816 | + | func (_e *MockContext_Expecter) OpenMovePanePicker() *MockContext_OpenMovePanePicker_Call { | |
| 817 | + | return &MockContext_OpenMovePanePicker_Call{Call: _e.mock.On("OpenMovePanePicker")} | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | func (_c *MockContext_OpenMovePanePicker_Call) Run(run func()) *MockContext_OpenMovePanePicker_Call { | |
| 821 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 822 | + | run() | |
| 823 | + | }) | |
| 824 | + | return _c | |
| 825 | + | } | |
| 826 | + | ||
| 827 | + | func (_c *MockContext_OpenMovePanePicker_Call) Return(err error) *MockContext_OpenMovePanePicker_Call { | |
| 828 | + | _c.Call.Return(err) | |
| 829 | + | return _c | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | func (_c *MockContext_OpenMovePanePicker_Call) RunAndReturn(run func() error) *MockContext_OpenMovePanePicker_Call { | |
| 833 | + | _c.Call.Return(run) | |
| 834 | + | return _c | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | // OpenMoveTabPicker provides a mock function for the type MockContext | |
| 838 | + | func (_mock *MockContext) OpenMoveTabPicker() error { | |
| 839 | + | ret := _mock.Called() | |
| 840 | + | ||
| 841 | + | if len(ret) == 0 { | |
| 842 | + | panic("no return value specified for OpenMoveTabPicker") | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | var r0 error | |
| 846 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 847 | + | r0 = returnFunc() | |
| 848 | + | } else { | |
| 849 | + | r0 = ret.Error(0) | |
| 850 | + | } | |
| 851 | + | return r0 | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | // MockContext_OpenMoveTabPicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenMoveTabPicker' | |
| 855 | + | type MockContext_OpenMoveTabPicker_Call struct { | |
| 856 | + | *mock.Call | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | // OpenMoveTabPicker is a helper method to define mock.On call | |
| 860 | + | func (_e *MockContext_Expecter) OpenMoveTabPicker() *MockContext_OpenMoveTabPicker_Call { | |
| 861 | + | return &MockContext_OpenMoveTabPicker_Call{Call: _e.mock.On("OpenMoveTabPicker")} | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | func (_c *MockContext_OpenMoveTabPicker_Call) Run(run func()) *MockContext_OpenMoveTabPicker_Call { | |
| 865 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 866 | + | run() | |
| 867 | + | }) | |
| 868 | + | return _c | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | func (_c *MockContext_OpenMoveTabPicker_Call) Return(err error) *MockContext_OpenMoveTabPicker_Call { | |
| 872 | + | _c.Call.Return(err) | |
| 873 | + | return _c | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | func (_c *MockContext_OpenMoveTabPicker_Call) RunAndReturn(run func() error) *MockContext_OpenMoveTabPicker_Call { | |
| 877 | + | _c.Call.Return(run) | |
| 878 | + | return _c | |
| 879 | + | } | |
| 880 | + | ||
| 793 | 881 | // OpenNotifications provides a mock function for the type MockContext | |
| 794 | 882 | func (_mock *MockContext) OpenNotifications() error { | |
| 795 | 883 | ret := _mock.Called() | |
| @@ -790,6 +790,94 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_ | |||||
| 790 | return _c | 790 | return _c | ||
| 791 | } | 791 | } | ||
| 792 | 792 | ||||
| 793 | + | // OpenMovePanePicker provides a mock function for the type MockContext | |||
| 794 | + | func (_mock *MockContext) OpenMovePanePicker() error { | |||
| 795 | + | ret := _mock.Called() | |||
| 796 | + | ||||
| 797 | + | if len(ret) == 0 { | |||
| 798 | + | panic("no return value specified for OpenMovePanePicker") | |||
| 799 | + | } | |||
| 800 | + | ||||
| 801 | + | var r0 error | |||
| 802 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 803 | + | r0 = returnFunc() | |||
| 804 | + | } else { | |||
| 805 | + | r0 = ret.Error(0) | |||
| 806 | + | } | |||
| 807 | + | return r0 | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | // MockContext_OpenMovePanePicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenMovePanePicker' | |||
| 811 | + | type MockContext_OpenMovePanePicker_Call struct { | |||
| 812 | + | *mock.Call | |||
| 813 | + | } | |||
| 814 | + | ||||
| 815 | + | // OpenMovePanePicker is a helper method to define mock.On call | |||
| 816 | + | func (_e *MockContext_Expecter) OpenMovePanePicker() *MockContext_OpenMovePanePicker_Call { | |||
| 817 | + | return &MockContext_OpenMovePanePicker_Call{Call: _e.mock.On("OpenMovePanePicker")} | |||
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | func (_c *MockContext_OpenMovePanePicker_Call) Run(run func()) *MockContext_OpenMovePanePicker_Call { | |||
| 821 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 822 | + | run() | |||
| 823 | + | }) | |||
| 824 | + | return _c | |||
| 825 | + | } | |||
| 826 | + | ||||
| 827 | + | func (_c *MockContext_OpenMovePanePicker_Call) Return(err error) *MockContext_OpenMovePanePicker_Call { | |||
| 828 | + | _c.Call.Return(err) | |||
| 829 | + | return _c | |||
| 830 | + | } | |||
| 831 | + | ||||
| 832 | + | func (_c *MockContext_OpenMovePanePicker_Call) RunAndReturn(run func() error) *MockContext_OpenMovePanePicker_Call { | |||
| 833 | + | _c.Call.Return(run) | |||
| 834 | + | return _c | |||
| 835 | + | } | |||
| 836 | + | ||||
| 837 | + | // OpenMoveTabPicker provides a mock function for the type MockContext | |||
| 838 | + | func (_mock *MockContext) OpenMoveTabPicker() error { | |||
| 839 | + | ret := _mock.Called() | |||
| 840 | + | ||||
| 841 | + | if len(ret) == 0 { | |||
| 842 | + | panic("no return value specified for OpenMoveTabPicker") | |||
| 843 | + | } | |||
| 844 | + | ||||
| 845 | + | var r0 error | |||
| 846 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 847 | + | r0 = returnFunc() | |||
| 848 | + | } else { | |||
| 849 | + | r0 = ret.Error(0) | |||
| 850 | + | } | |||
| 851 | + | return r0 | |||
| 852 | + | } | |||
| 853 | + | ||||
| 854 | + | // MockContext_OpenMoveTabPicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenMoveTabPicker' | |||
| 855 | + | type MockContext_OpenMoveTabPicker_Call struct { | |||
| 856 | + | *mock.Call | |||
| 857 | + | } | |||
| 858 | + | ||||
| 859 | + | // OpenMoveTabPicker is a helper method to define mock.On call | |||
| 860 | + | func (_e *MockContext_Expecter) OpenMoveTabPicker() *MockContext_OpenMoveTabPicker_Call { | |||
| 861 | + | return &MockContext_OpenMoveTabPicker_Call{Call: _e.mock.On("OpenMoveTabPicker")} | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | func (_c *MockContext_OpenMoveTabPicker_Call) Run(run func()) *MockContext_OpenMoveTabPicker_Call { | |||
| 865 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 866 | + | run() | |||
| 867 | + | }) | |||
| 868 | + | return _c | |||
| 869 | + | } | |||
| 870 | + | ||||
| 871 | + | func (_c *MockContext_OpenMoveTabPicker_Call) Return(err error) *MockContext_OpenMoveTabPicker_Call { | |||
| 872 | + | _c.Call.Return(err) | |||
| 873 | + | return _c | |||
| 874 | + | } | |||
| 875 | + | ||||
| 876 | + | func (_c *MockContext_OpenMoveTabPicker_Call) RunAndReturn(run func() error) *MockContext_OpenMoveTabPicker_Call { | |||
| 877 | + | _c.Call.Return(run) | |||
| 878 | + | return _c | |||
| 879 | + | } | |||
| 880 | + | ||||
| 793 | // OpenNotifications provides a mock function for the type MockContext | 881 | // OpenNotifications provides a mock function for the type MockContext | ||
| 794 | func (_mock *MockContext) OpenNotifications() error { | 882 | func (_mock *MockContext) OpenNotifications() error { | ||
| 795 | ret := _mock.Called() | 883 | ret := _mock.Called() | ||
M internal/usecase/command/registry.go +32 -0
| @@ -16,6 +16,8 @@ func Registry() []Command { | |||
| 16 | 16 | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | |
| 17 | 17 | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | |
| 18 | 18 | paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }), | |
| 19 | + | movePaneCommand(), | |
| 20 | + | moveTabCommand(), | |
| 19 | 21 | paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }), | |
| 20 | 22 | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), | |
| 21 | 23 | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), | |
| @@ -76,6 +78,36 @@ func paletteControlOne(slug, code, name, desc string, target TargetKind, run fun | |||
| 76 | 78 | return cmd | |
| 77 | 79 | } | |
| 78 | 80 | ||
| 81 | + | func movePaneCommand() Command { | |
| 82 | + | return Command{ | |
| 83 | + | Slug: "move-pane", Code: "MPN", Name: "Move pane to tab", | |
| 84 | + | Desc: "Move the focused pane to another live tab", Usage: "move-pane <destination-session> <destination-tab-id>", | |
| 85 | + | PaletteVisible: true, Scriptable: true, Target: TargetPane, Scope: CommandScopeCrossSession, | |
| 86 | + | Run: func(ctx Context, _ []string) error { return ctx.OpenMovePanePicker() }, | |
| 87 | + | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 88 | + | if len(args) != 2 || args[0] == "" || args[1] == "" { | |
| 89 | + | return ControlResult{}, ErrInvalidArguments | |
| 90 | + | } | |
| 91 | + | return ControlResult{}, ctx.MovePane(args[0], args[1]) | |
| 92 | + | }, | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func moveTabCommand() Command { | |
| 97 | + | return Command{ | |
| 98 | + | Slug: "move-tab", Code: "MTB", Name: "Move tab to session", | |
| 99 | + | Desc: "Move the active tab to another live session", Usage: "move-tab <destination-session>", | |
| 100 | + | PaletteVisible: true, Scriptable: true, Target: TargetTab, Scope: CommandScopeCrossSession, | |
| 101 | + | Run: func(ctx Context, _ []string) error { return ctx.OpenMoveTabPicker() }, | |
| 102 | + | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 103 | + | if len(args) != 1 || args[0] == "" { | |
| 104 | + | return ControlResult{}, ErrInvalidArguments | |
| 105 | + | } | |
| 106 | + | return ControlResult{}, ctx.MoveTab(args[0]) | |
| 107 | + | }, | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 79 | 111 | func toastCommand() Command { | |
| 80 | 112 | return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 81 | 113 | severity := "info" | |
| @@ -16,6 +16,8 @@ func Registry() []Command { | |||||
| 16 | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | 16 | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | ||
| 17 | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | 17 | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | ||
| 18 | paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }), | 18 | paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }), | ||
| 19 | + | movePaneCommand(), | |||
| 20 | + | moveTabCommand(), | |||
| 19 | paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }), | 21 | paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }), | ||
| 20 | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), | 22 | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), | ||
| 21 | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), | 23 | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), | ||
| @@ -76,6 +78,36 @@ func paletteControlOne(slug, code, name, desc string, target TargetKind, run fun | |||||
| 76 | return cmd | 78 | return cmd | ||
| 77 | } | 79 | } | ||
| 78 | 80 | ||||
| 81 | + | func movePaneCommand() Command { | |||
| 82 | + | return Command{ | |||
| 83 | + | Slug: "move-pane", Code: "MPN", Name: "Move pane to tab", | |||
| 84 | + | Desc: "Move the focused pane to another live tab", Usage: "move-pane <destination-session> <destination-tab-id>", | |||
| 85 | + | PaletteVisible: true, Scriptable: true, Target: TargetPane, Scope: CommandScopeCrossSession, | |||
| 86 | + | Run: func(ctx Context, _ []string) error { return ctx.OpenMovePanePicker() }, | |||
| 87 | + | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |||
| 88 | + | if len(args) != 2 || args[0] == "" || args[1] == "" { | |||
| 89 | + | return ControlResult{}, ErrInvalidArguments | |||
| 90 | + | } | |||
| 91 | + | return ControlResult{}, ctx.MovePane(args[0], args[1]) | |||
| 92 | + | }, | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func moveTabCommand() Command { | |||
| 97 | + | return Command{ | |||
| 98 | + | Slug: "move-tab", Code: "MTB", Name: "Move tab to session", | |||
| 99 | + | Desc: "Move the active tab to another live session", Usage: "move-tab <destination-session>", | |||
| 100 | + | PaletteVisible: true, Scriptable: true, Target: TargetTab, Scope: CommandScopeCrossSession, | |||
| 101 | + | Run: func(ctx Context, _ []string) error { return ctx.OpenMoveTabPicker() }, | |||
| 102 | + | Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |||
| 103 | + | if len(args) != 1 || args[0] == "" { | |||
| 104 | + | return ControlResult{}, ErrInvalidArguments | |||
| 105 | + | } | |||
| 106 | + | return ControlResult{}, ctx.MoveTab(args[0]) | |||
| 107 | + | }, | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 79 | func toastCommand() Command { | 111 | func toastCommand() Command { | ||
| 80 | return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | 112 | return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | ||
| 81 | severity := "info" | 113 | severity := "info" | ||
M internal/usecase/command/registry_test.go +97 -20
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 11 | 11 | commands := PaletteRegistry() | |
| 12 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | |
| 12 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | |
| 13 | 13 | ||
| 14 | 14 | if len(commands) != len(wantCodes) { | |
| 15 | 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -39,6 +39,76 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | func TestMoveCommandsExposeExactRegistryContracts(t *testing.T) { | |
| 43 | + | tests := []struct { | |
| 44 | + | code, slug, name, desc, usage string | |
| 45 | + | target TargetKind | |
| 46 | + | args []string | |
| 47 | + | wantCall string | |
| 48 | + | }{ | |
| 49 | + | { | |
| 50 | + | code: "MPN", slug: "move-pane", name: "Move pane to tab", | |
| 51 | + | desc: "Move the focused pane to another live tab", usage: "move-pane <destination-session> <destination-tab-id>", | |
| 52 | + | target: TargetPane, args: []string{"work", "t_dest"}, wantCall: "move-pane:work:t_dest", | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | code: "MTB", slug: "move-tab", name: "Move tab to session", | |
| 56 | + | desc: "Move the active tab to another live session", usage: "move-tab <destination-session>", | |
| 57 | + | target: TargetTab, args: []string{"work"}, wantCall: "move-tab:work", | |
| 58 | + | }, | |
| 59 | + | } | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.slug, func(t *testing.T) { | |
| 62 | + | cmd, ok := BySlug(tt.slug) | |
| 63 | + | if !ok { | |
| 64 | + | t.Fatalf("BySlug(%q) missing", tt.slug) | |
| 65 | + | } | |
| 66 | + | if cmd.Code != tt.code || cmd.Name != tt.name || cmd.Desc != tt.desc || cmd.Usage != tt.usage { | |
| 67 | + | t.Fatalf("command metadata = %#v", cmd) | |
| 68 | + | } | |
| 69 | + | if !cmd.PaletteVisible || !cmd.Scriptable || cmd.Target != tt.target || cmd.Scope != CommandScopeCrossSession { | |
| 70 | + | t.Fatalf("command visibility/target/scope = palette:%v scriptable:%v target:%v scope:%v", cmd.PaletteVisible, cmd.Scriptable, cmd.Target, cmd.Scope) | |
| 71 | + | } | |
| 72 | + | ctx := &controlSpy{} | |
| 73 | + | if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); err != nil { | |
| 74 | + | t.Fatalf("Control(%v): %v", tt.args, err) | |
| 75 | + | } | |
| 76 | + | if ctx.call != tt.wantCall { | |
| 77 | + | t.Fatalf("Control(%v) call = %q, want %q", tt.args, ctx.call, tt.wantCall) | |
| 78 | + | } | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestMoveControlHandlersRequireExactArguments(t *testing.T) { | |
| 84 | + | for _, tt := range []struct { | |
| 85 | + | slug string | |
| 86 | + | args []string | |
| 87 | + | }{ | |
| 88 | + | {slug: "move-pane"}, | |
| 89 | + | {slug: "move-pane", args: []string{"work"}}, | |
| 90 | + | {slug: "move-pane", args: []string{"work", "t_dest", "extra"}}, | |
| 91 | + | {slug: "move-pane", args: []string{"", "t_dest"}}, | |
| 92 | + | {slug: "move-tab"}, | |
| 93 | + | {slug: "move-tab", args: []string{"work", "extra"}}, | |
| 94 | + | {slug: "move-tab", args: []string{""}}, | |
| 95 | + | } { | |
| 96 | + | t.Run(tt.slug+"/"+strconv.Itoa(len(tt.args)), func(t *testing.T) { | |
| 97 | + | cmd, ok := BySlug(tt.slug) | |
| 98 | + | if !ok { | |
| 99 | + | t.Fatalf("BySlug(%q) missing", tt.slug) | |
| 100 | + | } | |
| 101 | + | ctx := &controlSpy{} | |
| 102 | + | if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); !errors.Is(err, ErrInvalidArguments) { | |
| 103 | + | t.Fatalf("Control(%v) error = %v, want ErrInvalidArguments", tt.args, err) | |
| 104 | + | } | |
| 105 | + | if ctx.call != "" { | |
| 106 | + | t.Fatalf("invalid arguments delegated: %q", ctx.call) | |
| 107 | + | } | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 42 | 112 | func TestBySlugExactMatch(t *testing.T) { | |
| 43 | 113 | tests := []struct { | |
| 44 | 114 | slug string | |
| @@ -70,6 +140,7 @@ func TestRegistryControlMetadata(t *testing.T) { | |||
| 70 | 140 | "new-session": TargetSession, "toast": TargetSession, | |
| 71 | 141 | "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane, | |
| 72 | 142 | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | |
| 143 | + | "move-pane": TargetPane, "move-tab": TargetTab, | |
| 73 | 144 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | |
| 74 | 145 | } | |
| 75 | 146 | notScriptable := []string{ | |
| @@ -188,25 +259,29 @@ func (s *controlSpy) CreateTab() error { return s.record("new-t | |||
| 188 | 259 | func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) } | |
| 189 | 260 | func (s *controlSpy) CloseTab() error { return s.record("close-tab") } | |
| 190 | 261 | func (s *controlSpy) ClosePane() error { return s.record("close-pane") } | |
| 191 | - | func (s *controlSpy) SplitRight() error { return s.record("split-right") } | |
| 192 | - | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | |
| 193 | - | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | |
| 194 | - | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | |
| 195 | - | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | |
| 196 | - | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | |
| 197 | - | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | |
| 198 | - | func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") } | |
| 199 | - | func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") } | |
| 200 | - | func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") } | |
| 201 | - | func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") } | |
| 202 | - | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | |
| 203 | - | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | |
| 204 | - | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | |
| 205 | - | func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") } | |
| 206 | - | func (s *controlSpy) NextTab() error { return s.record("next-tab") } | |
| 207 | - | func (s *controlSpy) PrevTab() error { return s.record("previous-tab") } | |
| 208 | - | func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) } | |
| 209 | - | func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) } | |
| 262 | + | func (s *controlSpy) MovePane(session, tab string) error { | |
| 263 | + | return s.record("move-pane:" + session + ":" + tab) | |
| 264 | + | } | |
| 265 | + | func (s *controlSpy) MoveTab(session string) error { return s.record("move-tab:" + session) } | |
| 266 | + | func (s *controlSpy) SplitRight() error { return s.record("split-right") } | |
| 267 | + | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | |
| 268 | + | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | |
| 269 | + | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | |
| 270 | + | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | |
| 271 | + | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | |
| 272 | + | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | |
| 273 | + | func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") } | |
| 274 | + | func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") } | |
| 275 | + | func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") } | |
| 276 | + | func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") } | |
| 277 | + | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | |
| 278 | + | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | |
| 279 | + | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | |
| 280 | + | func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") } | |
| 281 | + | func (s *controlSpy) NextTab() error { return s.record("next-tab") } | |
| 282 | + | func (s *controlSpy) PrevTab() error { return s.record("previous-tab") } | |
| 283 | + | func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) } | |
| 284 | + | func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) } | |
| 210 | 285 | func (s *controlSpy) Toast(level, message string) error { | |
| 211 | 286 | return s.record("toast:" + level + ":" + message) | |
| 212 | 287 | } | |
| @@ -289,6 +364,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 289 | 364 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | |
| 290 | 365 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | |
| 291 | 366 | {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }}, | |
| 367 | + | {code: "MPN", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMovePanePicker().Return(nil).Once() }}, | |
| 368 | + | {code: "MTB", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMoveTabPicker().Return(nil).Once() }}, | |
| 292 | 369 | {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }}, | |
| 293 | 370 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | |
| 294 | 371 | {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }}, | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | ||
| 11 | commands := PaletteRegistry() | 11 | commands := PaletteRegistry() | ||
| 12 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | 12 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} |
| 13 | 13 | ||||
| 14 | if len(commands) != len(wantCodes) { | 14 | if len(commands) != len(wantCodes) { | ||
| 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -39,6 +39,76 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |||||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | func TestMoveCommandsExposeExactRegistryContracts(t *testing.T) { | |||
| 43 | + | tests := []struct { | |||
| 44 | + | code, slug, name, desc, usage string | |||
| 45 | + | target TargetKind | |||
| 46 | + | args []string | |||
| 47 | + | wantCall string | |||
| 48 | + | }{ | |||
| 49 | + | { | |||
| 50 | + | code: "MPN", slug: "move-pane", name: "Move pane to tab", | |||
| 51 | + | desc: "Move the focused pane to another live tab", usage: "move-pane <destination-session> <destination-tab-id>", | |||
| 52 | + | target: TargetPane, args: []string{"work", "t_dest"}, wantCall: "move-pane:work:t_dest", | |||
| 53 | + | }, | |||
| 54 | + | { | |||
| 55 | + | code: "MTB", slug: "move-tab", name: "Move tab to session", | |||
| 56 | + | desc: "Move the active tab to another live session", usage: "move-tab <destination-session>", | |||
| 57 | + | target: TargetTab, args: []string{"work"}, wantCall: "move-tab:work", | |||
| 58 | + | }, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.slug, func(t *testing.T) { | |||
| 62 | + | cmd, ok := BySlug(tt.slug) | |||
| 63 | + | if !ok { | |||
| 64 | + | t.Fatalf("BySlug(%q) missing", tt.slug) | |||
| 65 | + | } | |||
| 66 | + | if cmd.Code != tt.code || cmd.Name != tt.name || cmd.Desc != tt.desc || cmd.Usage != tt.usage { | |||
| 67 | + | t.Fatalf("command metadata = %#v", cmd) | |||
| 68 | + | } | |||
| 69 | + | if !cmd.PaletteVisible || !cmd.Scriptable || cmd.Target != tt.target || cmd.Scope != CommandScopeCrossSession { | |||
| 70 | + | t.Fatalf("command visibility/target/scope = palette:%v scriptable:%v target:%v scope:%v", cmd.PaletteVisible, cmd.Scriptable, cmd.Target, cmd.Scope) | |||
| 71 | + | } | |||
| 72 | + | ctx := &controlSpy{} | |||
| 73 | + | if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); err != nil { | |||
| 74 | + | t.Fatalf("Control(%v): %v", tt.args, err) | |||
| 75 | + | } | |||
| 76 | + | if ctx.call != tt.wantCall { | |||
| 77 | + | t.Fatalf("Control(%v) call = %q, want %q", tt.args, ctx.call, tt.wantCall) | |||
| 78 | + | } | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestMoveControlHandlersRequireExactArguments(t *testing.T) { | |||
| 84 | + | for _, tt := range []struct { | |||
| 85 | + | slug string | |||
| 86 | + | args []string | |||
| 87 | + | }{ | |||
| 88 | + | {slug: "move-pane"}, | |||
| 89 | + | {slug: "move-pane", args: []string{"work"}}, | |||
| 90 | + | {slug: "move-pane", args: []string{"work", "t_dest", "extra"}}, | |||
| 91 | + | {slug: "move-pane", args: []string{"", "t_dest"}}, | |||
| 92 | + | {slug: "move-tab"}, | |||
| 93 | + | {slug: "move-tab", args: []string{"work", "extra"}}, | |||
| 94 | + | {slug: "move-tab", args: []string{""}}, | |||
| 95 | + | } { | |||
| 96 | + | t.Run(tt.slug+"/"+strconv.Itoa(len(tt.args)), func(t *testing.T) { | |||
| 97 | + | cmd, ok := BySlug(tt.slug) | |||
| 98 | + | if !ok { | |||
| 99 | + | t.Fatalf("BySlug(%q) missing", tt.slug) | |||
| 100 | + | } | |||
| 101 | + | ctx := &controlSpy{} | |||
| 102 | + | if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); !errors.Is(err, ErrInvalidArguments) { | |||
| 103 | + | t.Fatalf("Control(%v) error = %v, want ErrInvalidArguments", tt.args, err) | |||
| 104 | + | } | |||
| 105 | + | if ctx.call != "" { | |||
| 106 | + | t.Fatalf("invalid arguments delegated: %q", ctx.call) | |||
| 107 | + | } | |||
| 108 | + | }) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 42 | func TestBySlugExactMatch(t *testing.T) { | 112 | func TestBySlugExactMatch(t *testing.T) { | ||
| 43 | tests := []struct { | 113 | tests := []struct { | ||
| 44 | slug string | 114 | slug string | ||
| @@ -70,6 +140,7 @@ func TestRegistryControlMetadata(t *testing.T) { | |||||
| 70 | "new-session": TargetSession, "toast": TargetSession, | 140 | "new-session": TargetSession, "toast": TargetSession, | ||
| 71 | "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane, | 141 | "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane, | ||
| 72 | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | 142 | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | ||
| 143 | + | "move-pane": TargetPane, "move-tab": TargetTab, | |||
| 73 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | 144 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | ||
| 74 | } | 145 | } | ||
| 75 | notScriptable := []string{ | 146 | notScriptable := []string{ | ||
| @@ -188,25 +259,29 @@ func (s *controlSpy) CreateTab() error { return s.record("new-t | |||||
| 188 | func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) } | 259 | func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) } | ||
| 189 | func (s *controlSpy) CloseTab() error { return s.record("close-tab") } | 260 | func (s *controlSpy) CloseTab() error { return s.record("close-tab") } | ||
| 190 | func (s *controlSpy) ClosePane() error { return s.record("close-pane") } | 261 | func (s *controlSpy) ClosePane() error { return s.record("close-pane") } | ||
| 191 | - | func (s *controlSpy) SplitRight() error { return s.record("split-right") } | 262 | + | func (s *controlSpy) MovePane(session, tab string) error { |
| 192 | - | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | 263 | + | return s.record("move-pane:" + session + ":" + tab) |
| 193 | - | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | 264 | + | } |
| 194 | - | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | 265 | + | func (s *controlSpy) MoveTab(session string) error { return s.record("move-tab:" + session) } |
| 195 | - | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | 266 | + | func (s *controlSpy) SplitRight() error { return s.record("split-right") } |
| 196 | - | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | 267 | + | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } |
| 197 | - | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | 268 | + | func (s *controlSpy) SplitUp() error { return s.record("split-up") } |
| 198 | - | func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") } | 269 | + | func (s *controlSpy) SplitDown() error { return s.record("split-down") } |
| 199 | - | func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") } | 270 | + | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } |
| 200 | - | func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") } | 271 | + | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } |
| 201 | - | func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") } | 272 | + | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } |
| 202 | - | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | 273 | + | func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") } |
| 203 | - | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | 274 | + | func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") } |
| 204 | - | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | 275 | + | func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") } |
| 205 | - | func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") } | 276 | + | func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") } |
| 206 | - | func (s *controlSpy) NextTab() error { return s.record("next-tab") } | 277 | + | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } |
| 207 | - | func (s *controlSpy) PrevTab() error { return s.record("previous-tab") } | 278 | + | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } |
| 208 | - | func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) } | 279 | + | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } |
| 209 | - | func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) } | 280 | + | func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") } |
| 281 | + | func (s *controlSpy) NextTab() error { return s.record("next-tab") } | |||
| 282 | + | func (s *controlSpy) PrevTab() error { return s.record("previous-tab") } | |||
| 283 | + | func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) } | |||
| 284 | + | func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) } | |||
| 210 | func (s *controlSpy) Toast(level, message string) error { | 285 | func (s *controlSpy) Toast(level, message string) error { | ||
| 211 | return s.record("toast:" + level + ":" + message) | 286 | return s.record("toast:" + level + ":" + message) | ||
| 212 | } | 287 | } | ||
| @@ -289,6 +364,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 289 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | 364 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | ||
| 290 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | 365 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | ||
| 291 | {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }}, | 366 | {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }}, | ||
| 367 | + | {code: "MPN", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMovePanePicker().Return(nil).Once() }}, | |||
| 368 | + | {code: "MTB", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMoveTabPicker().Return(nil).Once() }}, | |||
| 292 | {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }}, | 369 | {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }}, | ||
| 293 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | 370 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | ||
| 294 | {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }}, | 371 | {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }}, | ||
M internal/usecase/daemon/control.go +64 -7
| @@ -52,6 +52,14 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque | |||
| 52 | 52 | if sess == nil { | |
| 53 | 53 | return commandFailure(code, text) | |
| 54 | 54 | } | |
| 55 | + | if cmd.Scope == command.CommandScopeCrossSession { | |
| 56 | + | tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request) | |
| 57 | + | if code != 0 { | |
| 58 | + | return commandFailure(code, text) | |
| 59 | + | } | |
| 60 | + | return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request) | |
| 61 | + | } | |
| 62 | + | ||
| 55 | 63 | sess.dispatchMu.Lock() | |
| 56 | 64 | defer sess.dispatchMu.Unlock() | |
| 57 | 65 | ||
| @@ -72,6 +80,8 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports | |||
| 72 | 80 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | |
| 73 | 81 | case errors.Is(err, errSessionNameInUse): | |
| 74 | 82 | return commandFailure(ports.ErrNameTaken, err.Error()) | |
| 83 | + | case isMoveCommandError(err): | |
| 84 | + | return moveCommandFailure(err) | |
| 75 | 85 | case errors.Is(err, layout.ErrNotInSplit): | |
| 76 | 86 | return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split") | |
| 77 | 87 | case errors.Is(err, layout.ErrTooSmall): | |
| @@ -86,8 +96,8 @@ func commandFailure(code uint16, text string) ports.CommandResult { | |||
| 86 | 96 | } | |
| 87 | 97 | ||
| 88 | 98 | // resolveTargetSession applies explicit-name, stable-ID, then unique-session | |
| 89 | - | // resolution. A name paired with IDs is advisory only when no live session has | |
| 90 | - | // that name (the VEV value can survive a rename). | |
| 99 | + | // resolution. A name paired with complete stable IDs is always advisory because | |
| 100 | + | // a process keeps its original VEV session component after relocation. | |
| 91 | 101 | func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) { | |
| 92 | 102 | d.mu.Lock() | |
| 93 | 103 | defer d.mu.Unlock() | |
| @@ -106,13 +116,18 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||
| 106 | 116 | if request.TargetTab == "" || request.TargetPane == "" { | |
| 107 | 117 | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | |
| 108 | 118 | } | |
| 119 | + | var match *session | |
| 109 | 120 | for _, sess := range d.sessions { | |
| 110 | - | if sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |
| 111 | - | if named != nil && named != sess { | |
| 112 | - | return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session" | |
| 113 | - | } | |
| 114 | - | return sess, 0, "" | |
| 121 | + | if !sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |
| 122 | + | continue | |
| 123 | + | } | |
| 124 | + | if match != nil { | |
| 125 | + | return nil, ports.ErrAmbiguousTarget, "target tab and pane IDs are ambiguous" | |
| 115 | 126 | } | |
| 127 | + | match = sess | |
| 128 | + | } | |
| 129 | + | if match != nil { | |
| 130 | + | return match, 0, "" | |
| 116 | 131 | } | |
| 117 | 132 | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | |
| 118 | 133 | } | |
| @@ -393,6 +408,48 @@ func (e controlExec) CloseTab() error { | |||
| 393 | 408 | func (e controlExec) ClosePane() error { | |
| 394 | 409 | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | |
| 395 | 410 | } | |
| 411 | + | ||
| 412 | + | func (e controlExec) MovePane(destinationSession, destinationTabID string) error { | |
| 413 | + | destination := e.d.liveSessionByName(destinationSession) | |
| 414 | + | if destination == nil { | |
| 415 | + | return errMoveStaleTarget | |
| 416 | + | } | |
| 417 | + | if e.sess == nil || e.tab == nil || e.target.pane == nil { | |
| 418 | + | return errMovePaneInvalid | |
| 419 | + | } | |
| 420 | + | return e.d.movePane(movePaneRequest{ | |
| 421 | + | Source: sessionMoveLocator(e.sess), | |
| 422 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 423 | + | SourcePaneID: domain.PaneStableID(e.target.pane.stableID), | |
| 424 | + | Destination: sessionMoveLocator(destination), | |
| 425 | + | DestinationTabID: domain.TabStableID(destinationTabID), | |
| 426 | + | }) | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | func (e controlExec) MoveTab(destinationSession string) error { | |
| 430 | + | destination := e.d.liveSessionByName(destinationSession) | |
| 431 | + | if destination == nil { | |
| 432 | + | return errMoveStaleTarget | |
| 433 | + | } | |
| 434 | + | if e.sess == nil || e.tab == nil { | |
| 435 | + | return errMovePaneInvalid | |
| 436 | + | } | |
| 437 | + | return e.d.moveTab(moveTabRequest{ | |
| 438 | + | Source: sessionMoveLocator(e.sess), | |
| 439 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 440 | + | Destination: sessionMoveLocator(destination), | |
| 441 | + | }) | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | func (d *Daemon) liveSessionByName(name string) *session { | |
| 445 | + | d.mu.Lock() | |
| 446 | + | defer d.mu.Unlock() | |
| 447 | + | return d.findByNameLocked(name) | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func sessionMoveLocator(sess *session) moveSessionLocator { | |
| 451 | + | return moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name} | |
| 452 | + | } | |
| 396 | 453 | func (e controlExec) split(direction layout.Direction) error { | |
| 397 | 454 | return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) | |
| 398 | 455 | } | |
| @@ -52,6 +52,14 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque | |||||
| 52 | if sess == nil { | 52 | if sess == nil { | ||
| 53 | return commandFailure(code, text) | 53 | return commandFailure(code, text) | ||
| 54 | } | 54 | } | ||
| 55 | + | if cmd.Scope == command.CommandScopeCrossSession { | |||
| 56 | + | tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request) | |||
| 57 | + | if code != 0 { | |||
| 58 | + | return commandFailure(code, text) | |||
| 59 | + | } | |||
| 60 | + | return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 55 | sess.dispatchMu.Lock() | 63 | sess.dispatchMu.Lock() | ||
| 56 | defer sess.dispatchMu.Unlock() | 64 | defer sess.dispatchMu.Unlock() | ||
| 57 | 65 | ||||
| @@ -72,6 +80,8 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports | |||||
| 72 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | 80 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | ||
| 73 | case errors.Is(err, errSessionNameInUse): | 81 | case errors.Is(err, errSessionNameInUse): | ||
| 74 | return commandFailure(ports.ErrNameTaken, err.Error()) | 82 | return commandFailure(ports.ErrNameTaken, err.Error()) | ||
| 83 | + | case isMoveCommandError(err): | |||
| 84 | + | return moveCommandFailure(err) | |||
| 75 | case errors.Is(err, layout.ErrNotInSplit): | 85 | case errors.Is(err, layout.ErrNotInSplit): | ||
| 76 | return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split") | 86 | return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split") | ||
| 77 | case errors.Is(err, layout.ErrTooSmall): | 87 | case errors.Is(err, layout.ErrTooSmall): | ||
| @@ -86,8 +96,8 @@ func commandFailure(code uint16, text string) ports.CommandResult { | |||||
| 86 | } | 96 | } | ||
| 87 | 97 | ||||
| 88 | // resolveTargetSession applies explicit-name, stable-ID, then unique-session | 98 | // resolveTargetSession applies explicit-name, stable-ID, then unique-session | ||
| 89 | - | // resolution. A name paired with IDs is advisory only when no live session has | 99 | + | // resolution. A name paired with complete stable IDs is always advisory because |
| 90 | - | // that name (the VEV value can survive a rename). | 100 | + | // a process keeps its original VEV session component after relocation. |
| 91 | func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) { | 101 | func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) { | ||
| 92 | d.mu.Lock() | 102 | d.mu.Lock() | ||
| 93 | defer d.mu.Unlock() | 103 | defer d.mu.Unlock() | ||
| @@ -106,13 +116,18 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||||
| 106 | if request.TargetTab == "" || request.TargetPane == "" { | 116 | if request.TargetTab == "" || request.TargetPane == "" { | ||
| 107 | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | 117 | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | ||
| 108 | } | 118 | } | ||
| 119 | + | var match *session | |||
| 109 | for _, sess := range d.sessions { | 120 | for _, sess := range d.sessions { | ||
| 110 | - | if sess.containsStableIDs(request.TargetTab, request.TargetPane) { | 121 | + | if !sess.containsStableIDs(request.TargetTab, request.TargetPane) { |
| 111 | - | if named != nil && named != sess { | 122 | + | continue |
| 112 | - | return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session" | 123 | + | } |
| 113 | - | } | 124 | + | if match != nil { |
| 114 | - | return sess, 0, "" | 125 | + | return nil, ports.ErrAmbiguousTarget, "target tab and pane IDs are ambiguous" |
| 115 | } | 126 | } | ||
| 127 | + | match = sess | |||
| 128 | + | } | |||
| 129 | + | if match != nil { | |||
| 130 | + | return match, 0, "" | |||
| 116 | } | 131 | } | ||
| 117 | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | 132 | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | ||
| 118 | } | 133 | } | ||
| @@ -393,6 +408,48 @@ func (e controlExec) CloseTab() error { | |||||
| 393 | func (e controlExec) ClosePane() error { | 408 | func (e controlExec) ClosePane() error { | ||
| 394 | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | 409 | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | ||
| 395 | } | 410 | } | ||
| 411 | + | ||||
| 412 | + | func (e controlExec) MovePane(destinationSession, destinationTabID string) error { | |||
| 413 | + | destination := e.d.liveSessionByName(destinationSession) | |||
| 414 | + | if destination == nil { | |||
| 415 | + | return errMoveStaleTarget | |||
| 416 | + | } | |||
| 417 | + | if e.sess == nil || e.tab == nil || e.target.pane == nil { | |||
| 418 | + | return errMovePaneInvalid | |||
| 419 | + | } | |||
| 420 | + | return e.d.movePane(movePaneRequest{ | |||
| 421 | + | Source: sessionMoveLocator(e.sess), | |||
| 422 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |||
| 423 | + | SourcePaneID: domain.PaneStableID(e.target.pane.stableID), | |||
| 424 | + | Destination: sessionMoveLocator(destination), | |||
| 425 | + | DestinationTabID: domain.TabStableID(destinationTabID), | |||
| 426 | + | }) | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func (e controlExec) MoveTab(destinationSession string) error { | |||
| 430 | + | destination := e.d.liveSessionByName(destinationSession) | |||
| 431 | + | if destination == nil { | |||
| 432 | + | return errMoveStaleTarget | |||
| 433 | + | } | |||
| 434 | + | if e.sess == nil || e.tab == nil { | |||
| 435 | + | return errMovePaneInvalid | |||
| 436 | + | } | |||
| 437 | + | return e.d.moveTab(moveTabRequest{ | |||
| 438 | + | Source: sessionMoveLocator(e.sess), | |||
| 439 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |||
| 440 | + | Destination: sessionMoveLocator(destination), | |||
| 441 | + | }) | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | func (d *Daemon) liveSessionByName(name string) *session { | |||
| 445 | + | d.mu.Lock() | |||
| 446 | + | defer d.mu.Unlock() | |||
| 447 | + | return d.findByNameLocked(name) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func sessionMoveLocator(sess *session) moveSessionLocator { | |||
| 451 | + | return moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name} | |||
| 452 | + | } | |||
| 396 | func (e controlExec) split(direction layout.Direction) error { | 453 | func (e controlExec) split(direction layout.Direction) error { | ||
| 397 | return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) | 454 | return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) | ||
| 398 | } | 455 | } | ||
M internal/usecase/daemon/control_test.go +358 -4
| @@ -98,10 +98,10 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |||
| 98 | 98 | {name: "missing stable IDs", arrange: func(d *Daemon) { | |
| 99 | 99 | addControlSession(d, "work", "t_work", "p_work") | |
| 100 | 100 | }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget}, | |
| 101 | - | {name: "cross-session IDs rejected", arrange: func(d *Daemon) { | |
| 102 | - | addControlSession(d, "one", "t_one", "p_one") | |
| 103 | - | addControlSession(d, "two", "t_two", "p_two") | |
| 104 | - | }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget}, | |
| 101 | + | {name: "duplicate stable IDs are ambiguous", arrange: func(d *Daemon) { | |
| 102 | + | addControlSession(d, "one", "t_shared", "p_shared") | |
| 103 | + | addControlSession(d, "two", "t_shared", "p_shared") | |
| 104 | + | }, request: ports.CommandRequest{Slug: "toast", Args: []string{"hello"}, TargetTab: "t_shared", TargetPane: "p_shared"}, code: ports.ErrAmbiguousTarget}, | |
| 105 | 105 | {name: "self requires both IDs", arrange: func(d *Daemon) { | |
| 106 | 106 | addControlSession(d, "work", "t_work", "p_work") | |
| 107 | 107 | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget}, | |
| @@ -636,6 +636,360 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||
| 636 | 636 | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | |
| 637 | 637 | } | |
| 638 | 638 | ||
| 639 | + | func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) { | |
| 640 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 641 | + | source := addControlSession(d, "work", "t_active", "p_active") | |
| 642 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 643 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |
| 644 | + | publishTiledPaneOwners(source, inactive) | |
| 645 | + | source.mu.Lock() | |
| 646 | + | source.tabs = append(source.tabs, inactive) | |
| 647 | + | source.active = 0 | |
| 648 | + | source.mu.Unlock() | |
| 649 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 650 | + | ||
| 651 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 652 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | |
| 653 | + | }) | |
| 654 | + | require.True(t, result.OK, result.Text) | |
| 655 | + | ||
| 656 | + | source.mu.Lock() | |
| 657 | + | require.Len(t, source.tabs, 1) | |
| 658 | + | require.Equal(t, "t_inactive", source.tabs[0].stableID) | |
| 659 | + | source.mu.Unlock() | |
| 660 | + | destTab := destination.tabs[0] | |
| 661 | + | destTab.mu.Lock() | |
| 662 | + | var moved *pane | |
| 663 | + | for _, candidate := range destTab.panes { | |
| 664 | + | if candidate.stableID == "p_active" { | |
| 665 | + | moved = candidate | |
| 666 | + | } | |
| 667 | + | } | |
| 668 | + | require.NotNil(t, moved) | |
| 669 | + | destTab.mu.Unlock() | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) { | |
| 673 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 674 | + | source := addControlSession(d, "work", "t_active", "p_active") | |
| 675 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 676 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |
| 677 | + | publishTiledPaneOwners(source, inactive) | |
| 678 | + | source.mu.Lock() | |
| 679 | + | source.tabs = append(source.tabs, inactive) | |
| 680 | + | source.active = 0 | |
| 681 | + | source.mu.Unlock() | |
| 682 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 683 | + | ||
| 684 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 685 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 686 | + | TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive", | |
| 687 | + | }) | |
| 688 | + | require.True(t, result.OK, result.Text) | |
| 689 | + | ||
| 690 | + | active := source.tabs[0] | |
| 691 | + | active.mu.Lock() | |
| 692 | + | require.Len(t, active.panes, 1) | |
| 693 | + | require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID) | |
| 694 | + | active.mu.Unlock() | |
| 695 | + | destTab := destination.tabs[0] | |
| 696 | + | destTab.mu.Lock() | |
| 697 | + | var moved *pane | |
| 698 | + | for _, candidate := range destTab.panes { | |
| 699 | + | if candidate.stableID == "p_inactive" { | |
| 700 | + | moved = candidate | |
| 701 | + | } | |
| 702 | + | } | |
| 703 | + | require.NotNil(t, moved) | |
| 704 | + | destTab.mu.Unlock() | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) { | |
| 708 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 709 | + | source := addControlSession(d, "work", "t_first", "p_first") | |
| 710 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 711 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 712 | + | publishTiledPaneOwners(source, second) | |
| 713 | + | source.mu.Lock() | |
| 714 | + | source.tabs = append(source.tabs, second) | |
| 715 | + | source.active = 1 | |
| 716 | + | source.mu.Unlock() | |
| 717 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 718 | + | ||
| 719 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 720 | + | Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", | |
| 721 | + | }) | |
| 722 | + | require.True(t, result.OK, result.Text) | |
| 723 | + | ||
| 724 | + | source.mu.Lock() | |
| 725 | + | require.Len(t, source.tabs, 1) | |
| 726 | + | require.Equal(t, "t_first", source.tabs[0].stableID) | |
| 727 | + | source.mu.Unlock() | |
| 728 | + | destination.mu.Lock() | |
| 729 | + | require.Len(t, destination.tabs, 2) | |
| 730 | + | require.Equal(t, "t_second", destination.tabs[1].stableID) | |
| 731 | + | destination.mu.Unlock() | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) { | |
| 735 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 736 | + | source := addControlSession(d, "work", "t_first", "p_first") | |
| 737 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 738 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 739 | + | publishTiledPaneOwners(source, second) | |
| 740 | + | source.mu.Lock() | |
| 741 | + | source.tabs = append(source.tabs, second) | |
| 742 | + | source.active = 0 | |
| 743 | + | source.mu.Unlock() | |
| 744 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 745 | + | ||
| 746 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 747 | + | Slug: "move-tab", Args: []string{"dest"}, Self: true, | |
| 748 | + | TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second", | |
| 749 | + | }) | |
| 750 | + | require.True(t, result.OK, result.Text) | |
| 751 | + | ||
| 752 | + | source.mu.Lock() | |
| 753 | + | require.Len(t, source.tabs, 1) | |
| 754 | + | require.Equal(t, "t_first", source.tabs[0].stableID) | |
| 755 | + | source.mu.Unlock() | |
| 756 | + | destination.mu.Lock() | |
| 757 | + | require.Len(t, destination.tabs, 2) | |
| 758 | + | require.Equal(t, "t_second", destination.tabs[1].stableID) | |
| 759 | + | destination.mu.Unlock() | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) { | |
| 763 | + | tests := []struct { | |
| 764 | + | name string | |
| 765 | + | arrange func(*Daemon) ports.CommandRequest | |
| 766 | + | code uint16 | |
| 767 | + | text string | |
| 768 | + | }{ | |
| 769 | + | { | |
| 770 | + | name: "move-pane missing destination session", | |
| 771 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 772 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 773 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"} | |
| 774 | + | }, | |
| 775 | + | code: ports.ErrNoSuchTarget, | |
| 776 | + | text: "move request is invalid", | |
| 777 | + | }, | |
| 778 | + | { | |
| 779 | + | name: "move-pane unknown destination session", | |
| 780 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 781 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 782 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"} | |
| 783 | + | }, | |
| 784 | + | code: ports.ErrNoSuchTarget, | |
| 785 | + | text: "move target is no longer available", | |
| 786 | + | }, | |
| 787 | + | { | |
| 788 | + | name: "move-pane destination tab not in session", | |
| 789 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 790 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 791 | + | addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 792 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"} | |
| 793 | + | }, | |
| 794 | + | code: ports.ErrNoSuchTarget, | |
| 795 | + | text: "move target is no longer available", | |
| 796 | + | }, | |
| 797 | + | { | |
| 798 | + | name: "move-pane same source tab", | |
| 799 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 800 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 801 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"} | |
| 802 | + | }, | |
| 803 | + | code: ports.ErrNoSuchTarget, | |
| 804 | + | text: "move request is invalid", | |
| 805 | + | }, | |
| 806 | + | { | |
| 807 | + | name: "move-tab same session", | |
| 808 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 809 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 810 | + | return ports.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"} | |
| 811 | + | }, | |
| 812 | + | code: ports.ErrNoSuchTarget, | |
| 813 | + | text: "move request is invalid", | |
| 814 | + | }, | |
| 815 | + | { | |
| 816 | + | name: "move-tab stopping destination", | |
| 817 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 818 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 819 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 820 | + | destination.teardownMu.Lock() | |
| 821 | + | destination.teardownActive = true | |
| 822 | + | destination.teardownMu.Unlock() | |
| 823 | + | return ports.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"} | |
| 824 | + | }, | |
| 825 | + | code: ports.ErrNoSuchTarget, | |
| 826 | + | text: "move target is no longer available", | |
| 827 | + | }, | |
| 828 | + | { | |
| 829 | + | name: "move-pane too few args", | |
| 830 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 831 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 832 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"} | |
| 833 | + | }, | |
| 834 | + | code: ports.ErrInvalidCommandArgs, | |
| 835 | + | }, | |
| 836 | + | { | |
| 837 | + | name: "move-pane too many args", | |
| 838 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 839 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 840 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"} | |
| 841 | + | }, | |
| 842 | + | code: ports.ErrInvalidCommandArgs, | |
| 843 | + | }, | |
| 844 | + | { | |
| 845 | + | name: "move-tab missing destination", | |
| 846 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 847 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 848 | + | return ports.CommandRequest{Slug: "move-tab", TargetSession: "work"} | |
| 849 | + | }, | |
| 850 | + | code: ports.ErrInvalidCommandArgs, | |
| 851 | + | }, | |
| 852 | + | { | |
| 853 | + | name: "explicit session name without IDs remains authoritative", | |
| 854 | + | arrange: func(d *Daemon) ports.CommandRequest { | |
| 855 | + | addControlSession(d, "renamed", "t_work", "p_work") | |
| 856 | + | addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 857 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"} | |
| 858 | + | }, | |
| 859 | + | code: ports.ErrNoSuchTarget, | |
| 860 | + | text: "no such session: old-name", | |
| 861 | + | }, | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | for _, tt := range tests { | |
| 865 | + | t.Run(tt.name, func(t *testing.T) { | |
| 866 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 867 | + | request := tt.arrange(d) | |
| 868 | + | result := sendCommand(t, d, request) | |
| 869 | + | require.False(t, result.OK) | |
| 870 | + | require.Equal(t, tt.code, result.Code) | |
| 871 | + | if tt.text != "" { | |
| 872 | + | require.Equal(t, tt.text, result.Text) | |
| 873 | + | } | |
| 874 | + | }) | |
| 875 | + | } | |
| 876 | + | } | |
| 877 | + | ||
| 878 | + | func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) { | |
| 879 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 880 | + | advisory := addControlSession(d, "old-name", "t_other", "p_other") | |
| 881 | + | source := addControlSession(d, "renamed", "t_work", "p_work") | |
| 882 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 883 | + | ||
| 884 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 885 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 886 | + | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | |
| 887 | + | }) | |
| 888 | + | require.True(t, result.OK, result.Text) | |
| 889 | + | ||
| 890 | + | source.mu.Lock() | |
| 891 | + | require.Len(t, source.tabs, 0) | |
| 892 | + | source.mu.Unlock() | |
| 893 | + | advisory.mu.Lock() | |
| 894 | + | require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched") | |
| 895 | + | advisory.mu.Unlock() | |
| 896 | + | destTab := destination.tabs[0] | |
| 897 | + | destTab.mu.Lock() | |
| 898 | + | require.Len(t, destTab.panes, 2) | |
| 899 | + | destTab.mu.Unlock() | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) { | |
| 903 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 904 | + | source := addControlSession(d, "renamed", "t_active", "p_active") | |
| 905 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 906 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |
| 907 | + | publishTiledPaneOwners(source, inactive) | |
| 908 | + | source.mu.Lock() | |
| 909 | + | source.tabs = append(source.tabs, inactive) | |
| 910 | + | source.active = 1 | |
| 911 | + | source.mu.Unlock() | |
| 912 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |
| 913 | + | ||
| 914 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 915 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, | |
| 916 | + | TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive", | |
| 917 | + | }) | |
| 918 | + | require.True(t, result.OK, result.Text) | |
| 919 | + | ||
| 920 | + | source.mu.Lock() | |
| 921 | + | require.Len(t, source.tabs, 1) | |
| 922 | + | require.Equal(t, "t_active", source.tabs[0].stableID) | |
| 923 | + | source.mu.Unlock() | |
| 924 | + | destTab := destination.tabs[0] | |
| 925 | + | destTab.mu.Lock() | |
| 926 | + | var moved *pane | |
| 927 | + | for _, candidate := range destTab.panes { | |
| 928 | + | if candidate.stableID == "p_inactive" { | |
| 929 | + | moved = candidate | |
| 930 | + | } | |
| 931 | + | } | |
| 932 | + | require.NotNil(t, moved) | |
| 933 | + | destTab.mu.Unlock() | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) { | |
| 937 | + | d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |
| 938 | + | left.mu.Lock() | |
| 939 | + | left.name = "left" | |
| 940 | + | left.tabs[0].stableID = "left-moved" | |
| 941 | + | left.active = 0 | |
| 942 | + | left.mu.Unlock() | |
| 943 | + | ||
| 944 | + | right := addMoveTabTestSession(d, "right", "right-stays") | |
| 945 | + | rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 946 | + | rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx) | |
| 947 | + | publishTiledPaneOwners(right, rightMoved) | |
| 948 | + | right.mu.Lock() | |
| 949 | + | right.tabs = append(right.tabs, rightMoved) | |
| 950 | + | right.active = 1 | |
| 951 | + | right.mu.Unlock() | |
| 952 | + | ||
| 953 | + | start := make(chan struct{}) | |
| 954 | + | done := make(chan ports.CommandResult, 2) | |
| 955 | + | go func() { | |
| 956 | + | <-start | |
| 957 | + | done <- sendCommand(t, d, ports.CommandRequest{ | |
| 958 | + | Slug: "move-tab", Args: []string{"right"}, TargetSession: "left", | |
| 959 | + | }) | |
| 960 | + | }() | |
| 961 | + | go func() { | |
| 962 | + | <-start | |
| 963 | + | done <- sendCommand(t, d, ports.CommandRequest{ | |
| 964 | + | Slug: "move-tab", Args: []string{"left"}, TargetSession: "right", | |
| 965 | + | }) | |
| 966 | + | }() | |
| 967 | + | close(start) | |
| 968 | + | for range 2 { | |
| 969 | + | select { | |
| 970 | + | case result := <-done: | |
| 971 | + | require.True(t, result.OK, result.Text) | |
| 972 | + | case <-time.After(5 * time.Second): | |
| 973 | + | t.Fatal("opposite-direction move commands deadlocked") | |
| 974 | + | } | |
| 975 | + | } | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session { | |
| 979 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 980 | + | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 981 | + | tb.ctx, tb.cancel = context.WithCancel(ctx) | |
| 982 | + | sess := &session{ | |
| 983 | + | id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5}, | |
| 984 | + | ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0, | |
| 985 | + | } | |
| 986 | + | publishTiledPaneOwners(sess, tb) | |
| 987 | + | d.mu.Lock() | |
| 988 | + | d.sessions[sess.id] = sess | |
| 989 | + | d.mu.Unlock() | |
| 990 | + | return sess | |
| 991 | + | } | |
| 992 | + | ||
| 639 | 993 | func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame { | |
| 640 | 994 | t.Helper() | |
| 641 | 995 | if request.Version == 0 { | |
| @@ -98,10 +98,10 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |||||
| 98 | {name: "missing stable IDs", arrange: func(d *Daemon) { | 98 | {name: "missing stable IDs", arrange: func(d *Daemon) { | ||
| 99 | addControlSession(d, "work", "t_work", "p_work") | 99 | addControlSession(d, "work", "t_work", "p_work") | ||
| 100 | }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget}, | 100 | }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget}, | ||
| 101 | - | {name: "cross-session IDs rejected", arrange: func(d *Daemon) { | 101 | + | {name: "duplicate stable IDs are ambiguous", arrange: func(d *Daemon) { |
| 102 | - | addControlSession(d, "one", "t_one", "p_one") | 102 | + | addControlSession(d, "one", "t_shared", "p_shared") |
| 103 | - | addControlSession(d, "two", "t_two", "p_two") | 103 | + | addControlSession(d, "two", "t_shared", "p_shared") |
| 104 | - | }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget}, | 104 | + | }, request: ports.CommandRequest{Slug: "toast", Args: []string{"hello"}, TargetTab: "t_shared", TargetPane: "p_shared"}, code: ports.ErrAmbiguousTarget}, |
| 105 | {name: "self requires both IDs", arrange: func(d *Daemon) { | 105 | {name: "self requires both IDs", arrange: func(d *Daemon) { | ||
| 106 | addControlSession(d, "work", "t_work", "p_work") | 106 | addControlSession(d, "work", "t_work", "p_work") | ||
| 107 | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget}, | 107 | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget}, | ||
| @@ -636,6 +636,360 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||||
| 636 | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | 636 | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | ||
| 637 | } | 637 | } | ||
| 638 | 638 | ||||
| 639 | + | func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) { | |||
| 640 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 641 | + | source := addControlSession(d, "work", "t_active", "p_active") | |||
| 642 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 643 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |||
| 644 | + | publishTiledPaneOwners(source, inactive) | |||
| 645 | + | source.mu.Lock() | |||
| 646 | + | source.tabs = append(source.tabs, inactive) | |||
| 647 | + | source.active = 0 | |||
| 648 | + | source.mu.Unlock() | |||
| 649 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 650 | + | ||||
| 651 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 652 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | |||
| 653 | + | }) | |||
| 654 | + | require.True(t, result.OK, result.Text) | |||
| 655 | + | ||||
| 656 | + | source.mu.Lock() | |||
| 657 | + | require.Len(t, source.tabs, 1) | |||
| 658 | + | require.Equal(t, "t_inactive", source.tabs[0].stableID) | |||
| 659 | + | source.mu.Unlock() | |||
| 660 | + | destTab := destination.tabs[0] | |||
| 661 | + | destTab.mu.Lock() | |||
| 662 | + | var moved *pane | |||
| 663 | + | for _, candidate := range destTab.panes { | |||
| 664 | + | if candidate.stableID == "p_active" { | |||
| 665 | + | moved = candidate | |||
| 666 | + | } | |||
| 667 | + | } | |||
| 668 | + | require.NotNil(t, moved) | |||
| 669 | + | destTab.mu.Unlock() | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) { | |||
| 673 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 674 | + | source := addControlSession(d, "work", "t_active", "p_active") | |||
| 675 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 676 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |||
| 677 | + | publishTiledPaneOwners(source, inactive) | |||
| 678 | + | source.mu.Lock() | |||
| 679 | + | source.tabs = append(source.tabs, inactive) | |||
| 680 | + | source.active = 0 | |||
| 681 | + | source.mu.Unlock() | |||
| 682 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 683 | + | ||||
| 684 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 685 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |||
| 686 | + | TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive", | |||
| 687 | + | }) | |||
| 688 | + | require.True(t, result.OK, result.Text) | |||
| 689 | + | ||||
| 690 | + | active := source.tabs[0] | |||
| 691 | + | active.mu.Lock() | |||
| 692 | + | require.Len(t, active.panes, 1) | |||
| 693 | + | require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID) | |||
| 694 | + | active.mu.Unlock() | |||
| 695 | + | destTab := destination.tabs[0] | |||
| 696 | + | destTab.mu.Lock() | |||
| 697 | + | var moved *pane | |||
| 698 | + | for _, candidate := range destTab.panes { | |||
| 699 | + | if candidate.stableID == "p_inactive" { | |||
| 700 | + | moved = candidate | |||
| 701 | + | } | |||
| 702 | + | } | |||
| 703 | + | require.NotNil(t, moved) | |||
| 704 | + | destTab.mu.Unlock() | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) { | |||
| 708 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 709 | + | source := addControlSession(d, "work", "t_first", "p_first") | |||
| 710 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 711 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |||
| 712 | + | publishTiledPaneOwners(source, second) | |||
| 713 | + | source.mu.Lock() | |||
| 714 | + | source.tabs = append(source.tabs, second) | |||
| 715 | + | source.active = 1 | |||
| 716 | + | source.mu.Unlock() | |||
| 717 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 718 | + | ||||
| 719 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 720 | + | Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", | |||
| 721 | + | }) | |||
| 722 | + | require.True(t, result.OK, result.Text) | |||
| 723 | + | ||||
| 724 | + | source.mu.Lock() | |||
| 725 | + | require.Len(t, source.tabs, 1) | |||
| 726 | + | require.Equal(t, "t_first", source.tabs[0].stableID) | |||
| 727 | + | source.mu.Unlock() | |||
| 728 | + | destination.mu.Lock() | |||
| 729 | + | require.Len(t, destination.tabs, 2) | |||
| 730 | + | require.Equal(t, "t_second", destination.tabs[1].stableID) | |||
| 731 | + | destination.mu.Unlock() | |||
| 732 | + | } | |||
| 733 | + | ||||
| 734 | + | func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) { | |||
| 735 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 736 | + | source := addControlSession(d, "work", "t_first", "p_first") | |||
| 737 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 738 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |||
| 739 | + | publishTiledPaneOwners(source, second) | |||
| 740 | + | source.mu.Lock() | |||
| 741 | + | source.tabs = append(source.tabs, second) | |||
| 742 | + | source.active = 0 | |||
| 743 | + | source.mu.Unlock() | |||
| 744 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 745 | + | ||||
| 746 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 747 | + | Slug: "move-tab", Args: []string{"dest"}, Self: true, | |||
| 748 | + | TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second", | |||
| 749 | + | }) | |||
| 750 | + | require.True(t, result.OK, result.Text) | |||
| 751 | + | ||||
| 752 | + | source.mu.Lock() | |||
| 753 | + | require.Len(t, source.tabs, 1) | |||
| 754 | + | require.Equal(t, "t_first", source.tabs[0].stableID) | |||
| 755 | + | source.mu.Unlock() | |||
| 756 | + | destination.mu.Lock() | |||
| 757 | + | require.Len(t, destination.tabs, 2) | |||
| 758 | + | require.Equal(t, "t_second", destination.tabs[1].stableID) | |||
| 759 | + | destination.mu.Unlock() | |||
| 760 | + | } | |||
| 761 | + | ||||
| 762 | + | func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) { | |||
| 763 | + | tests := []struct { | |||
| 764 | + | name string | |||
| 765 | + | arrange func(*Daemon) ports.CommandRequest | |||
| 766 | + | code uint16 | |||
| 767 | + | text string | |||
| 768 | + | }{ | |||
| 769 | + | { | |||
| 770 | + | name: "move-pane missing destination session", | |||
| 771 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 772 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 773 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"} | |||
| 774 | + | }, | |||
| 775 | + | code: ports.ErrNoSuchTarget, | |||
| 776 | + | text: "move request is invalid", | |||
| 777 | + | }, | |||
| 778 | + | { | |||
| 779 | + | name: "move-pane unknown destination session", | |||
| 780 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 781 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 782 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"} | |||
| 783 | + | }, | |||
| 784 | + | code: ports.ErrNoSuchTarget, | |||
| 785 | + | text: "move target is no longer available", | |||
| 786 | + | }, | |||
| 787 | + | { | |||
| 788 | + | name: "move-pane destination tab not in session", | |||
| 789 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 790 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 791 | + | addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 792 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"} | |||
| 793 | + | }, | |||
| 794 | + | code: ports.ErrNoSuchTarget, | |||
| 795 | + | text: "move target is no longer available", | |||
| 796 | + | }, | |||
| 797 | + | { | |||
| 798 | + | name: "move-pane same source tab", | |||
| 799 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 800 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 801 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"} | |||
| 802 | + | }, | |||
| 803 | + | code: ports.ErrNoSuchTarget, | |||
| 804 | + | text: "move request is invalid", | |||
| 805 | + | }, | |||
| 806 | + | { | |||
| 807 | + | name: "move-tab same session", | |||
| 808 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 809 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 810 | + | return ports.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"} | |||
| 811 | + | }, | |||
| 812 | + | code: ports.ErrNoSuchTarget, | |||
| 813 | + | text: "move request is invalid", | |||
| 814 | + | }, | |||
| 815 | + | { | |||
| 816 | + | name: "move-tab stopping destination", | |||
| 817 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 818 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 819 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 820 | + | destination.teardownMu.Lock() | |||
| 821 | + | destination.teardownActive = true | |||
| 822 | + | destination.teardownMu.Unlock() | |||
| 823 | + | return ports.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"} | |||
| 824 | + | }, | |||
| 825 | + | code: ports.ErrNoSuchTarget, | |||
| 826 | + | text: "move target is no longer available", | |||
| 827 | + | }, | |||
| 828 | + | { | |||
| 829 | + | name: "move-pane too few args", | |||
| 830 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 831 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 832 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"} | |||
| 833 | + | }, | |||
| 834 | + | code: ports.ErrInvalidCommandArgs, | |||
| 835 | + | }, | |||
| 836 | + | { | |||
| 837 | + | name: "move-pane too many args", | |||
| 838 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 839 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 840 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"} | |||
| 841 | + | }, | |||
| 842 | + | code: ports.ErrInvalidCommandArgs, | |||
| 843 | + | }, | |||
| 844 | + | { | |||
| 845 | + | name: "move-tab missing destination", | |||
| 846 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 847 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 848 | + | return ports.CommandRequest{Slug: "move-tab", TargetSession: "work"} | |||
| 849 | + | }, | |||
| 850 | + | code: ports.ErrInvalidCommandArgs, | |||
| 851 | + | }, | |||
| 852 | + | { | |||
| 853 | + | name: "explicit session name without IDs remains authoritative", | |||
| 854 | + | arrange: func(d *Daemon) ports.CommandRequest { | |||
| 855 | + | addControlSession(d, "renamed", "t_work", "p_work") | |||
| 856 | + | addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 857 | + | return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"} | |||
| 858 | + | }, | |||
| 859 | + | code: ports.ErrNoSuchTarget, | |||
| 860 | + | text: "no such session: old-name", | |||
| 861 | + | }, | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | for _, tt := range tests { | |||
| 865 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 866 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 867 | + | request := tt.arrange(d) | |||
| 868 | + | result := sendCommand(t, d, request) | |||
| 869 | + | require.False(t, result.OK) | |||
| 870 | + | require.Equal(t, tt.code, result.Code) | |||
| 871 | + | if tt.text != "" { | |||
| 872 | + | require.Equal(t, tt.text, result.Text) | |||
| 873 | + | } | |||
| 874 | + | }) | |||
| 875 | + | } | |||
| 876 | + | } | |||
| 877 | + | ||||
| 878 | + | func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) { | |||
| 879 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 880 | + | advisory := addControlSession(d, "old-name", "t_other", "p_other") | |||
| 881 | + | source := addControlSession(d, "renamed", "t_work", "p_work") | |||
| 882 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 883 | + | ||||
| 884 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 885 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |||
| 886 | + | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | |||
| 887 | + | }) | |||
| 888 | + | require.True(t, result.OK, result.Text) | |||
| 889 | + | ||||
| 890 | + | source.mu.Lock() | |||
| 891 | + | require.Len(t, source.tabs, 0) | |||
| 892 | + | source.mu.Unlock() | |||
| 893 | + | advisory.mu.Lock() | |||
| 894 | + | require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched") | |||
| 895 | + | advisory.mu.Unlock() | |||
| 896 | + | destTab := destination.tabs[0] | |||
| 897 | + | destTab.mu.Lock() | |||
| 898 | + | require.Len(t, destTab.panes, 2) | |||
| 899 | + | destTab.mu.Unlock() | |||
| 900 | + | } | |||
| 901 | + | ||||
| 902 | + | func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) { | |||
| 903 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 904 | + | source := addControlSession(d, "renamed", "t_active", "p_active") | |||
| 905 | + | inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 906 | + | inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx) | |||
| 907 | + | publishTiledPaneOwners(source, inactive) | |||
| 908 | + | source.mu.Lock() | |||
| 909 | + | source.tabs = append(source.tabs, inactive) | |||
| 910 | + | source.active = 1 | |||
| 911 | + | source.mu.Unlock() | |||
| 912 | + | destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest") | |||
| 913 | + | ||||
| 914 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 915 | + | Slug: "move-pane", Args: []string{"dest", "t_dest"}, | |||
| 916 | + | TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive", | |||
| 917 | + | }) | |||
| 918 | + | require.True(t, result.OK, result.Text) | |||
| 919 | + | ||||
| 920 | + | source.mu.Lock() | |||
| 921 | + | require.Len(t, source.tabs, 1) | |||
| 922 | + | require.Equal(t, "t_active", source.tabs[0].stableID) | |||
| 923 | + | source.mu.Unlock() | |||
| 924 | + | destTab := destination.tabs[0] | |||
| 925 | + | destTab.mu.Lock() | |||
| 926 | + | var moved *pane | |||
| 927 | + | for _, candidate := range destTab.panes { | |||
| 928 | + | if candidate.stableID == "p_inactive" { | |||
| 929 | + | moved = candidate | |||
| 930 | + | } | |||
| 931 | + | } | |||
| 932 | + | require.NotNil(t, moved) | |||
| 933 | + | destTab.mu.Unlock() | |||
| 934 | + | } | |||
| 935 | + | ||||
| 936 | + | func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) { | |||
| 937 | + | d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY()) | |||
| 938 | + | left.mu.Lock() | |||
| 939 | + | left.name = "left" | |||
| 940 | + | left.tabs[0].stableID = "left-moved" | |||
| 941 | + | left.active = 0 | |||
| 942 | + | left.mu.Unlock() | |||
| 943 | + | ||||
| 944 | + | right := addMoveTabTestSession(d, "right", "right-stays") | |||
| 945 | + | rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |||
| 946 | + | rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx) | |||
| 947 | + | publishTiledPaneOwners(right, rightMoved) | |||
| 948 | + | right.mu.Lock() | |||
| 949 | + | right.tabs = append(right.tabs, rightMoved) | |||
| 950 | + | right.active = 1 | |||
| 951 | + | right.mu.Unlock() | |||
| 952 | + | ||||
| 953 | + | start := make(chan struct{}) | |||
| 954 | + | done := make(chan ports.CommandResult, 2) | |||
| 955 | + | go func() { | |||
| 956 | + | <-start | |||
| 957 | + | done <- sendCommand(t, d, ports.CommandRequest{ | |||
| 958 | + | Slug: "move-tab", Args: []string{"right"}, TargetSession: "left", | |||
| 959 | + | }) | |||
| 960 | + | }() | |||
| 961 | + | go func() { | |||
| 962 | + | <-start | |||
| 963 | + | done <- sendCommand(t, d, ports.CommandRequest{ | |||
| 964 | + | Slug: "move-tab", Args: []string{"left"}, TargetSession: "right", | |||
| 965 | + | }) | |||
| 966 | + | }() | |||
| 967 | + | close(start) | |||
| 968 | + | for range 2 { | |||
| 969 | + | select { | |||
| 970 | + | case result := <-done: | |||
| 971 | + | require.True(t, result.OK, result.Text) | |||
| 972 | + | case <-time.After(5 * time.Second): | |||
| 973 | + | t.Fatal("opposite-direction move commands deadlocked") | |||
| 974 | + | } | |||
| 975 | + | } | |||
| 976 | + | } | |||
| 977 | + | ||||
| 978 | + | func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session { | |||
| 979 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |||
| 980 | + | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |||
| 981 | + | tb.ctx, tb.cancel = context.WithCancel(ctx) | |||
| 982 | + | sess := &session{ | |||
| 983 | + | id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5}, | |||
| 984 | + | ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0, | |||
| 985 | + | } | |||
| 986 | + | publishTiledPaneOwners(sess, tb) | |||
| 987 | + | d.mu.Lock() | |||
| 988 | + | d.sessions[sess.id] = sess | |||
| 989 | + | d.mu.Unlock() | |||
| 990 | + | return sess | |||
| 991 | + | } | |||
| 992 | + | ||||
| 639 | func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame { | 993 | func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame { | ||
| 640 | t.Helper() | 994 | t.Helper() | ||
| 641 | if request.Version == 0 { | 995 | if request.Version == 0 { | ||
M internal/usecase/daemon/move.go +5 -4
| @@ -25,7 +25,8 @@ type moveTabRequest struct { | |||
| 25 | 25 | // fallible work happens before the membership publication section. Once the | |
| 26 | 26 | // section starts, the caller holds the ordered architecture locks and only | |
| 27 | 27 | // non-failing in-memory writes are permitted. | |
| 28 | - | func (d *Daemon) movePane(req movePaneRequest) error { | |
| 28 | + | func (d *Daemon) movePane(req movePaneRequest) (result error) { | |
| 29 | + | defer func() { result = normalizeMoveRejection(result) }() | |
| 29 | 30 | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || | |
| 30 | 31 | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | |
| 31 | 32 | return errMovePaneInvalid | |
| @@ -36,12 +37,12 @@ func (d *Daemon) movePane(req movePaneRequest) error { | |||
| 36 | 37 | destination := moveSessionForLocatorLocked(d, req.Destination) | |
| 37 | 38 | d.mu.Unlock() | |
| 38 | 39 | if source == nil || destination == nil { | |
| 39 | - | return errMovePaneInvalid | |
| 40 | + | return errMoveStaleTarget | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 43 | 44 | if err != nil { | |
| 44 | - | return err | |
| 45 | + | return errMoveStaleTarget | |
| 45 | 46 | } | |
| 46 | 47 | reservationHeld := true | |
| 47 | 48 | defer func() { | |
| @@ -188,7 +189,7 @@ func (d *Daemon) movePane(req movePaneRequest) error { | |||
| 188 | 189 | if commit.err != nil { | |
| 189 | 190 | return commit.err | |
| 190 | 191 | } | |
| 191 | - | return errMovePaneInvalid | |
| 192 | + | return errMoveStaleTarget | |
| 192 | 193 | } | |
| 193 | 194 | // Owner/layout fences end at the atomic publication boundary. The immutable | |
| 194 | 195 | // postcommit plan owns every later cleanup and effect. | |
| @@ -25,7 +25,8 @@ type moveTabRequest struct { | |||||
| 25 | // fallible work happens before the membership publication section. Once the | 25 | // fallible work happens before the membership publication section. Once the | ||
| 26 | // section starts, the caller holds the ordered architecture locks and only | 26 | // section starts, the caller holds the ordered architecture locks and only | ||
| 27 | // non-failing in-memory writes are permitted. | 27 | // non-failing in-memory writes are permitted. | ||
| 28 | - | func (d *Daemon) movePane(req movePaneRequest) error { | 28 | + | func (d *Daemon) movePane(req movePaneRequest) (result error) { |
| 29 | + | defer func() { result = normalizeMoveRejection(result) }() | |||
| 29 | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || | 30 | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || | ||
| 30 | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | 31 | req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" { | ||
| 31 | return errMovePaneInvalid | 32 | return errMovePaneInvalid | ||
| @@ -36,12 +37,12 @@ func (d *Daemon) movePane(req movePaneRequest) error { | |||||
| 36 | destination := moveSessionForLocatorLocked(d, req.Destination) | 37 | destination := moveSessionForLocatorLocked(d, req.Destination) | ||
| 37 | d.mu.Unlock() | 38 | d.mu.Unlock() | ||
| 38 | if source == nil || destination == nil { | 39 | if source == nil || destination == nil { | ||
| 39 | - | return errMovePaneInvalid | 40 | + | return errMoveStaleTarget |
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | reservation, err := d.reserveMoveLifecycles(source, destination) | 43 | reservation, err := d.reserveMoveLifecycles(source, destination) | ||
| 43 | if err != nil { | 44 | if err != nil { | ||
| 44 | - | return err | 45 | + | return errMoveStaleTarget |
| 45 | } | 46 | } | ||
| 46 | reservationHeld := true | 47 | reservationHeld := true | ||
| 47 | defer func() { | 48 | defer func() { | ||
| @@ -188,7 +189,7 @@ func (d *Daemon) movePane(req movePaneRequest) error { | |||||
| 188 | if commit.err != nil { | 189 | if commit.err != nil { | ||
| 189 | return commit.err | 190 | return commit.err | ||
| 190 | } | 191 | } | ||
| 191 | - | return errMovePaneInvalid | 192 | + | return errMoveStaleTarget |
| 192 | } | 193 | } | ||
| 193 | // Owner/layout fences end at the atomic publication boundary. The immutable | 194 | // Owner/layout fences end at the atomic publication boundary. The immutable | ||
| 194 | // postcommit plan owns every later cleanup and effect. | 195 | // postcommit plan owns every later cleanup and effect. | ||
M internal/usecase/daemon/move_pane_admission.go +7 -4
| @@ -26,17 +26,20 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||
| 26 | 26 | d.mu.Lock() | |
| 27 | 27 | defer d.mu.Unlock() | |
| 28 | 28 | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | |
| 29 | - | return nil, errMovePaneInvalid | |
| 29 | + | return nil, errMoveStaleTarget | |
| 30 | 30 | } | |
| 31 | 31 | unlockSessions := lockAttachmentSessions(source, destination) | |
| 32 | 32 | defer unlockSessions() | |
| 33 | 33 | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 34 | - | return nil, errMovePaneInvalid | |
| 34 | + | return nil, errMoveStaleTarget | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | sourceTab := findMoveTabLocked(source, req.SourceTabID) | |
| 38 | 38 | destinationTab := findMoveTabLocked(destination, req.DestinationTabID) | |
| 39 | - | if sourceTab == nil || destinationTab == nil || sourceTab == destinationTab { | |
| 39 | + | if sourceTab == nil || destinationTab == nil { | |
| 40 | + | return nil, errMoveStaleTarget | |
| 41 | + | } | |
| 42 | + | if sourceTab == destinationTab { | |
| 40 | 43 | return nil, errMovePaneInvalid | |
| 41 | 44 | } | |
| 42 | 45 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| @@ -50,7 +53,7 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||
| 50 | 53 | } | |
| 51 | 54 | } | |
| 52 | 55 | if movedPane == nil { | |
| 53 | - | return nil, errMovePaneInvalid | |
| 56 | + | return nil, errMoveStaleTarget | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 59 | return &movePaneAdmission{ | |
| @@ -26,17 +26,20 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||||
| 26 | d.mu.Lock() | 26 | d.mu.Lock() | ||
| 27 | defer d.mu.Unlock() | 27 | defer d.mu.Unlock() | ||
| 28 | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | 28 | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | ||
| 29 | - | return nil, errMovePaneInvalid | 29 | + | return nil, errMoveStaleTarget |
| 30 | } | 30 | } | ||
| 31 | unlockSessions := lockAttachmentSessions(source, destination) | 31 | unlockSessions := lockAttachmentSessions(source, destination) | ||
| 32 | defer unlockSessions() | 32 | defer unlockSessions() | ||
| 33 | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | 33 | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | ||
| 34 | - | return nil, errMovePaneInvalid | 34 | + | return nil, errMoveStaleTarget |
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | sourceTab := findMoveTabLocked(source, req.SourceTabID) | 37 | sourceTab := findMoveTabLocked(source, req.SourceTabID) | ||
| 38 | destinationTab := findMoveTabLocked(destination, req.DestinationTabID) | 38 | destinationTab := findMoveTabLocked(destination, req.DestinationTabID) | ||
| 39 | - | if sourceTab == nil || destinationTab == nil || sourceTab == destinationTab { | 39 | + | if sourceTab == nil || destinationTab == nil { |
| 40 | + | return nil, errMoveStaleTarget | |||
| 41 | + | } | |||
| 42 | + | if sourceTab == destinationTab { | |||
| 40 | return nil, errMovePaneInvalid | 43 | return nil, errMovePaneInvalid | ||
| 41 | } | 44 | } | ||
| 42 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | 45 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | ||
| @@ -50,7 +53,7 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat | |||||
| 50 | } | 53 | } | ||
| 51 | } | 54 | } | ||
| 52 | if movedPane == nil { | 55 | if movedPane == nil { | ||
| 53 | - | return nil, errMovePaneInvalid | 56 | + | return nil, errMoveStaleTarget |
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 56 | return &movePaneAdmission{ | 59 | return &movePaneAdmission{ | ||
M internal/usecase/daemon/move_pane_candidate.go +13 -13
| @@ -9,12 +9,6 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | var ( | |
| 13 | - | errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane") | |
| 14 | - | errMovePaneIDExhausted = errors.New("destination pane IDs exhausted") | |
| 15 | - | errMovePaneInvalid = errors.New("move pane request is no longer valid") | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | 12 | // movePaneCandidate contains only unpublished topology and identity changes. | |
| 19 | 13 | // The caller must revalidate the live tabs before committing these values. | |
| 20 | 14 | type movePaneCandidate struct { | |
| @@ -41,7 +35,7 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC | |||
| 41 | 35 | source.tree == nil || source.tree.Root == nil || source.panes[moved.id] != moved || | |
| 42 | 36 | !layout.ContainsLeaf(source.tree.Root, moved.id) || destination.tree == nil || destination.tree.Root == nil || | |
| 43 | 37 | destination.panes[destination.tree.Focus] == nil || !layout.ContainsLeaf(destination.tree.Root, destination.tree.Focus) { | |
| 44 | - | return nil, layout.ErrNotFound | |
| 38 | + | return nil, errMovePaneInvalid | |
| 45 | 39 | } | |
| 46 | 40 | ||
| 47 | 41 | candidate := &movePaneCandidate{ | |
| @@ -53,19 +47,22 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC | |||
| 53 | 47 | ||
| 54 | 48 | sourceLeaves := layout.LeafIDs(source.tree.Root) | |
| 55 | 49 | if len(sourceLeaves) == 1 { | |
| 56 | - | if source.floating.state != floatingUninitialized { | |
| 57 | - | return nil, errMovePaneFloatingSibling | |
| 50 | + | switch source.floating.state { | |
| 51 | + | case floatingWarming: | |
| 52 | + | return nil, errMoveFloatingWarming | |
| 53 | + | case floatingHidden, floatingVisible: | |
| 54 | + | return nil, errMoveFinalSourceFloating | |
| 58 | 55 | } | |
| 59 | 56 | candidate.removeSourceTab = true | |
| 60 | 57 | } else { | |
| 61 | 58 | candidate.sourceTree = source.tree.Clone() | |
| 62 | 59 | if err := candidate.sourceTree.Close(moved.id); err != nil { | |
| 63 | - | return nil, err | |
| 60 | + | return nil, errMovePaneInvalid | |
| 64 | 61 | } | |
| 65 | 62 | var ok bool | |
| 66 | 63 | candidate.sourcePlacements, ok = layout.Solve(candidate.sourceTree.Root, tabArea(source)) | |
| 67 | 64 | if !ok || candidate.sourceTree.Focus == "" || !layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus) { | |
| 68 | - | return nil, layout.ErrTooSmall | |
| 65 | + | return nil, errMoveTooSmall | |
| 69 | 66 | } | |
| 70 | 67 | } | |
| 71 | 68 | ||
| @@ -77,12 +74,15 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC | |||
| 77 | 74 | candidate.destinationTree = destination.tree.Clone() | |
| 78 | 75 | area := tabArea(destination) | |
| 79 | 76 | if err := candidate.destinationTree.Split(destination.tree.Focus, layout.Right, true, candidate.destinationID, area); err != nil { | |
| 80 | - | return nil, err | |
| 77 | + | if errors.Is(err, layout.ErrTooSmall) { | |
| 78 | + | return nil, errMoveTooSmall | |
| 79 | + | } | |
| 80 | + | return nil, errMovePaneInvalid | |
| 81 | 81 | } | |
| 82 | 82 | var ok bool | |
| 83 | 83 | candidate.destinationPlacements, ok = layout.Solve(candidate.destinationTree.Root, area) | |
| 84 | 84 | if !ok { | |
| 85 | - | return nil, layout.ErrTooSmall | |
| 85 | + | return nil, errMoveTooSmall | |
| 86 | 86 | } | |
| 87 | 87 | return candidate, nil | |
| 88 | 88 | } | |
| @@ -9,12 +9,6 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | var ( | |||
| 13 | - | errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane") | |||
| 14 | - | errMovePaneIDExhausted = errors.New("destination pane IDs exhausted") | |||
| 15 | - | errMovePaneInvalid = errors.New("move pane request is no longer valid") | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | // movePaneCandidate contains only unpublished topology and identity changes. | 12 | // movePaneCandidate contains only unpublished topology and identity changes. | ||
| 19 | // The caller must revalidate the live tabs before committing these values. | 13 | // The caller must revalidate the live tabs before committing these values. | ||
| 20 | type movePaneCandidate struct { | 14 | type movePaneCandidate struct { | ||
| @@ -41,7 +35,7 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC | |||||
| 41 | source.tree == nil || source.tree.Root == nil || source.panes[moved.id] != moved || | 35 | source.tree == nil || source.tree.Root == nil || source.panes[moved.id] != moved || | ||
| 42 | !layout.ContainsLeaf(source.tree.Root, moved.id) || destination.tree == nil || destination.tree.Root == nil || | 36 | !layout.ContainsLeaf(source.tree.Root, moved.id) || destination.tree == nil || destination.tree.Root == nil || | ||
| 43 | destination.panes[destination.tree.Focus] == nil || !layout.ContainsLeaf(destination.tree.Root, destination.tree.Focus) { | 37 | destination.panes[destination.tree.Focus] == nil || !layout.ContainsLeaf(destination.tree.Root, destination.tree.Focus) { | ||
| 44 | - | return nil, layout.ErrNotFound | 38 | + | return nil, errMovePaneInvalid |
| 45 | } | 39 | } | ||
| 46 | 40 | ||||
| 47 | candidate := &movePaneCandidate{ | 41 | candidate := &movePaneCandidate{ | ||
| @@ -53,19 +47,22 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC | |||||
| 53 | 47 | ||||
| 54 | sourceLeaves := layout.LeafIDs(source.tree.Root) | 48 | sourceLeaves := layout.LeafIDs(source.tree.Root) | ||
| 55 | if len(sourceLeaves) == 1 { | 49 | if len(sourceLeaves) == 1 { | ||
| 56 | - | if source.floating.state != floatingUninitialized { | 50 | + | switch source.floating.state { |
| 57 | - | return nil, errMovePaneFloatingSibling | 51 | + | case floatingWarming: |
| 52 | + | return nil, errMoveFloatingWarming | |||
| 53 | + | case floatingHidden, floatingVisible: | |||
| 54 | + | return nil, errMoveFinalSourceFloating | |||
| 58 | } | 55 | } | ||
| 59 | candidate.removeSourceTab = true | 56 | candidate.removeSourceTab = true | ||
| 60 | } else { | 57 | } else { | ||
| 61 | candidate.sourceTree = source.tree.Clone() | 58 | candidate.sourceTree = source.tree.Clone() | ||
| 62 | if err := candidate.sourceTree.Close(moved.id); err != nil { | 59 | if err := candidate.sourceTree.Close(moved.id); err != nil { | ||
| 63 | - | return nil, err | 60 | + | return nil, errMovePaneInvalid |
| 64 | } | 61 | } | ||
| 65 | var ok bool | 62 | var ok bool | ||
| 66 | candidate.sourcePlacements, ok = layout.Solve(candidate.sourceTree.Root, tabArea(source)) | 63 | candidate.sourcePlacements, ok = layout.Solve(candidate.sourceTree.Root, tabArea(source)) | ||
| 67 | if !ok || candidate.sourceTree.Focus == "" || !layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus) { | 64 | if !ok || candidate.sourceTree.Focus == "" || !layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus) { | ||
| 68 | - | return nil, layout.ErrTooSmall | 65 | + | return nil, errMoveTooSmall |
| 69 | } | 66 | } | ||
| 70 | } | 67 | } | ||
| 71 | 68 | ||||
| @@ -77,12 +74,15 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC | |||||
| 77 | candidate.destinationTree = destination.tree.Clone() | 74 | candidate.destinationTree = destination.tree.Clone() | ||
| 78 | area := tabArea(destination) | 75 | area := tabArea(destination) | ||
| 79 | if err := candidate.destinationTree.Split(destination.tree.Focus, layout.Right, true, candidate.destinationID, area); err != nil { | 76 | if err := candidate.destinationTree.Split(destination.tree.Focus, layout.Right, true, candidate.destinationID, area); err != nil { | ||
| 80 | - | return nil, err | 77 | + | if errors.Is(err, layout.ErrTooSmall) { |
| 78 | + | return nil, errMoveTooSmall | |||
| 79 | + | } | |||
| 80 | + | return nil, errMovePaneInvalid | |||
| 81 | } | 81 | } | ||
| 82 | var ok bool | 82 | var ok bool | ||
| 83 | candidate.destinationPlacements, ok = layout.Solve(candidate.destinationTree.Root, area) | 83 | candidate.destinationPlacements, ok = layout.Solve(candidate.destinationTree.Root, area) | ||
| 84 | if !ok { | 84 | if !ok { | ||
| 85 | - | return nil, layout.ErrTooSmall | 85 | + | return nil, errMoveTooSmall |
| 86 | } | 86 | } | ||
| 87 | return candidate, nil | 87 | return candidate, nil | ||
| 88 | } | 88 | } | ||
A internal/usecase/daemon/move_picker_test.go +320 -0
| @@ -0,0 +1,320 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) { | |
| 15 | + | t.Helper() | |
| 16 | + | sourcePTY, releaseSource := newBlockingPTY(t) | |
| 17 | + | d, source, ac, _ := newManualSessionWithPTYs(t, sourcePTY) | |
| 18 | + | source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1} | |
| 19 | + | delete(d.sessions, domain.SessionID("manual")) | |
| 20 | + | d.sessions[source.id] = source | |
| 21 | + | sourceTab := source.tabs[0] | |
| 22 | + | sourceTab.stableID = "source-tab" | |
| 23 | + | sourcePane := sourceTab.focusedPane() | |
| 24 | + | sourcePane.stableID = "source-pane" | |
| 25 | + | ||
| 26 | + | releases := []func(){releaseSource} | |
| 27 | + | destPTY, releaseDest := newBlockingPTY(t) | |
| 28 | + | releases = append(releases, releaseDest) | |
| 29 | + | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 30 | + | publishTiledPaneOwners(source, sourceTab) | |
| 31 | + | destination := &session{ | |
| 32 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 33 | + | ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0, | |
| 34 | + | } | |
| 35 | + | publishTiledPaneOwners(destination, destinationTab) | |
| 36 | + | for range extraDestinationTabs { | |
| 37 | + | extraPTY, releaseExtra := newBlockingPTY(t) | |
| 38 | + | releases = append(releases, releaseExtra) | |
| 39 | + | extraTab := newTabWithStableID("extra-tab", "extra-pane", extraPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 40 | + | destination.tabs = append(destination.tabs, extraTab) | |
| 41 | + | publishTiledPaneOwners(destination, extraTab) | |
| 42 | + | } | |
| 43 | + | d.mu.Lock() | |
| 44 | + | d.sessions[destination.id] = destination | |
| 45 | + | d.mu.Unlock() | |
| 46 | + | return d, source, ac, destination, destinationTab, releases | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) { | |
| 50 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 51 | + | defer releaseAll(releases) | |
| 52 | + | ||
| 53 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 54 | + | require.True(t, ac.overlays.paletteActive()) | |
| 55 | + | d.handleInput(source, ac, []byte("MPN\r")) | |
| 56 | + | require.False(t, ac.overlays.paletteActive()) | |
| 57 | + | require.True(t, ac.overlays.pickerActive()) | |
| 58 | + | ||
| 59 | + | ac.overlays.pickerMu.Lock() | |
| 60 | + | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource | |
| 61 | + | ac.overlays.pickerMu.Unlock() | |
| 62 | + | require.Equal(t, pickerMovePane, intent) | |
| 63 | + | require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session) | |
| 64 | + | require.Equal(t, domain.TabStableID("source-tab"), captured.TabID) | |
| 65 | + | require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID) | |
| 66 | + | require.Same(t, ac, captured.Client) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) { | |
| 70 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 71 | + | defer releaseAll(releases) | |
| 72 | + | sourceTab := source.tabs[0] | |
| 73 | + | sourceTab.stableID = "active-tab" | |
| 74 | + | ||
| 75 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 76 | + | d.handleInput(source, ac, []byte("MTB\r")) | |
| 77 | + | require.True(t, ac.overlays.pickerActive()) | |
| 78 | + | ||
| 79 | + | ac.overlays.pickerMu.Lock() | |
| 80 | + | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource | |
| 81 | + | ac.overlays.pickerMu.Unlock() | |
| 82 | + | require.Equal(t, pickerMoveTab, intent) | |
| 83 | + | require.Equal(t, domain.TabStableID("active-tab"), captured.TabID) | |
| 84 | + | require.Equal(t, domain.PaneStableID(""), captured.PaneID) | |
| 85 | + | require.Same(t, ac, captured.Client) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) { | |
| 89 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 90 | + | defer releaseAll(releases) | |
| 91 | + | d.mu.Lock() | |
| 92 | + | delete(d.sessions, domain.SessionID("destination")) | |
| 93 | + | d.mu.Unlock() | |
| 94 | + | ||
| 95 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 96 | + | d.handleInput(source, ac, []byte("MPN\r")) | |
| 97 | + | ||
| 98 | + | require.False(t, ac.overlays.pickerActive()) | |
| 99 | + | history := d.notices.history() | |
| 100 | + | require.NotEmpty(t, history) | |
| 101 | + | require.Equal(t, "No destination available.", history[0].Message) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestMovePickerEnterCommitsMovePane(t *testing.T) { | |
| 105 | + | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 106 | + | defer releaseAll(releases) | |
| 107 | + | source.mu.Lock() | |
| 108 | + | source.client = nil | |
| 109 | + | source.mu.Unlock() | |
| 110 | + | sourceTab := source.tabs[0] | |
| 111 | + | moved := sourceTab.focusedPane() | |
| 112 | + | ||
| 113 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 114 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 115 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 116 | + | })) | |
| 117 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 118 | + | ||
| 119 | + | require.False(t, ac.overlays.pickerActive()) | |
| 120 | + | require.Nil(t, source.tabs) | |
| 121 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 122 | + | require.Same(t, destination, moved.ownerSnapshot().session) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) { | |
| 126 | + | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |
| 127 | + | defer releaseAll(releases) | |
| 128 | + | source.mu.Lock() | |
| 129 | + | source.client = nil | |
| 130 | + | source.mu.Unlock() | |
| 131 | + | sourceTab := source.tabs[0] | |
| 132 | + | moved := sourceTab.focusedPane() | |
| 133 | + | ||
| 134 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 135 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 136 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 137 | + | })) | |
| 138 | + | ac.overlays.pickerMu.Lock() | |
| 139 | + | target, ok := ac.overlays.picker.Selected() | |
| 140 | + | ac.overlays.pickerMu.Unlock() | |
| 141 | + | require.True(t, ok) | |
| 142 | + | require.NoError(t, d.commitMovePickerSelection(pickerMovePane, moveSourceLocator{ | |
| 143 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 144 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 145 | + | }, target)) | |
| 146 | + | ||
| 147 | + | require.Nil(t, source.tabs) | |
| 148 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |
| 149 | + | require.Same(t, destination, moved.ownerSnapshot().session) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestMovePickerEnterCommitsMoveTab(t *testing.T) { | |
| 153 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 154 | + | defer releaseAll(releases) | |
| 155 | + | source.mu.Lock() | |
| 156 | + | source.client = nil | |
| 157 | + | source.mu.Unlock() | |
| 158 | + | movedTab := source.tabs[0] | |
| 159 | + | movedTab.stableID = "moved-tab" | |
| 160 | + | ||
| 161 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{ | |
| 162 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 163 | + | TabID: "moved-tab", | |
| 164 | + | })) | |
| 165 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 166 | + | ||
| 167 | + | require.False(t, ac.overlays.pickerActive()) | |
| 168 | + | require.Nil(t, source.tabs) | |
| 169 | + | require.Len(t, destination.tabs, 2) | |
| 170 | + | require.Same(t, movedTab, destination.tabs[1]) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestMovePickerEscapePerformsNoMutation(t *testing.T) { | |
| 174 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 175 | + | defer releaseAll(releases) | |
| 176 | + | before := len(source.tabs) | |
| 177 | + | ||
| 178 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 179 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 180 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 181 | + | })) | |
| 182 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 183 | + | d.clock = clk | |
| 184 | + | d.handlePickerInput(ac, []byte("\x1b")) | |
| 185 | + | timer := <-clk.timers | |
| 186 | + | timer.ch <- time.Now() | |
| 187 | + | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | |
| 188 | + | require.Len(t, source.tabs, before) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) { | |
| 192 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 193 | + | defer releaseAll(releases) | |
| 194 | + | ||
| 195 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 196 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 197 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 198 | + | })) | |
| 199 | + | d.mu.Lock() | |
| 200 | + | delete(d.sessions, destination.id) | |
| 201 | + | d.mu.Unlock() | |
| 202 | + | ||
| 203 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 204 | + | ||
| 205 | + | require.False(t, ac.overlays.pickerActive()) | |
| 206 | + | history := d.notices.history() | |
| 207 | + | require.NotEmpty(t, history) | |
| 208 | + | require.Equal(t, "Destination is no longer available.", history[0].Message) | |
| 209 | + | require.Len(t, source.tabs, 1) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) { | |
| 213 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 214 | + | defer releaseAll(releases) | |
| 215 | + | sourceTab := source.tabs[0] | |
| 216 | + | ||
| 217 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 218 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 219 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 220 | + | })) | |
| 221 | + | sourceTab.mu.Lock() | |
| 222 | + | delete(sourceTab.panes, sourceTab.tree.Focus) | |
| 223 | + | sourceTab.mu.Unlock() | |
| 224 | + | ||
| 225 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 226 | + | ||
| 227 | + | require.True(t, ac.overlays.pickerActive()) | |
| 228 | + | history := d.notices.history() | |
| 229 | + | require.NotEmpty(t, history) | |
| 230 | + | require.Equal(t, "Pane no longer exists.", history[0].Message) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) { | |
| 234 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 235 | + | defer releaseAll(releases) | |
| 236 | + | ||
| 237 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{ | |
| 238 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 239 | + | TabID: "source-tab", | |
| 240 | + | })) | |
| 241 | + | source.mu.Lock() | |
| 242 | + | source.tabs = nil | |
| 243 | + | source.mu.Unlock() | |
| 244 | + | ||
| 245 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 246 | + | ||
| 247 | + | require.True(t, ac.overlays.pickerActive()) | |
| 248 | + | history := d.notices.history() | |
| 249 | + | require.NotEmpty(t, history) | |
| 250 | + | require.Equal(t, "Tab no longer exists.", history[0].Message) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) { | |
| 254 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 255 | + | defer releaseAll(releases) | |
| 256 | + | ||
| 257 | + | require.NoError(t, paletteExec{d: d, sess: source, ac: ac}.OpenMovePanePicker()) | |
| 258 | + | replacement := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size} | |
| 259 | + | replacement.initOverlays() | |
| 260 | + | replacement.setSession(source) | |
| 261 | + | source.mu.Lock() | |
| 262 | + | source.client = replacement | |
| 263 | + | source.mu.Unlock() | |
| 264 | + | ||
| 265 | + | d.handlePickerInput(ac, []byte("\r")) | |
| 266 | + | ||
| 267 | + | require.True(t, ac.overlays.pickerActive()) | |
| 268 | + | require.Len(t, source.tabs, 1) | |
| 269 | + | require.Len(t, destination.tabs, 1) | |
| 270 | + | history := d.notices.history() | |
| 271 | + | require.NotEmpty(t, history) | |
| 272 | + | require.Equal(t, "Source client is no longer active.", history[0].Message) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) { | |
| 276 | + | d, source, follower, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 277 | + | defer releaseAll(releases) | |
| 278 | + | follower.replaceTransport(&closeTrackingTransport{}) | |
| 279 | + | require.NotNil(t, d.attachCoordinator(source, nil, follower, true)) | |
| 280 | + | ||
| 281 | + | displacedTransport := &closeTrackingTransport{} | |
| 282 | + | displaced := &attachedClient{tr: displacedTransport, output: newOutputStateStream(), size: follower.size} | |
| 283 | + | displaced.initOverlays() | |
| 284 | + | displaced.setSession(destination) | |
| 285 | + | destination.mu.Lock() | |
| 286 | + | destination.client = displaced | |
| 287 | + | destination.mu.Unlock() | |
| 288 | + | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |
| 289 | + | ||
| 290 | + | var pickerClosed atomic.Bool | |
| 291 | + | d.afterDisplacedCleanupStarted = func() { | |
| 292 | + | require.False(t, follower.overlays.pickerActive(), "picker must close before snatch finalization") | |
| 293 | + | pickerClosed.Store(true) | |
| 294 | + | } | |
| 295 | + | defer func() { d.afterDisplacedCleanupStarted = nil }() | |
| 296 | + | ||
| 297 | + | require.NoError(t, d.enterPickerForIntent(source, follower, pickerMovePane, moveSourceLocator{ | |
| 298 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 299 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 300 | + | })) | |
| 301 | + | d.handlePickerInput(follower, []byte("\r")) | |
| 302 | + | d.attachmentCleanupWg.Wait() | |
| 303 | + | ||
| 304 | + | require.True(t, pickerClosed.Load()) | |
| 305 | + | require.False(t, follower.overlays.pickerActive()) | |
| 306 | + | frames := displacedTransport.Sends() | |
| 307 | + | require.NotEmpty(t, frames) | |
| 308 | + | require.Equal(t, ports.MsgOutput, frames[0].Type) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) { | |
| 312 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 313 | + | defer releaseAll(releases) | |
| 314 | + | ||
| 315 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 316 | + | d.handleInput(source, ac, []byte("MPN\r")) | |
| 317 | + | require.True(t, ac.overlays.pickerActive()) | |
| 318 | + | require.True(t, source.dispatchMu.TryLock(), "dispatchMu must not remain held while the move picker is open") | |
| 319 | + | source.dispatchMu.Unlock() | |
| 320 | + | } | |
| @@ -0,0 +1,320 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync/atomic" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) { | |||
| 15 | + | t.Helper() | |||
| 16 | + | sourcePTY, releaseSource := newBlockingPTY(t) | |||
| 17 | + | d, source, ac, _ := newManualSessionWithPTYs(t, sourcePTY) | |||
| 18 | + | source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1} | |||
| 19 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 20 | + | d.sessions[source.id] = source | |||
| 21 | + | sourceTab := source.tabs[0] | |||
| 22 | + | sourceTab.stableID = "source-tab" | |||
| 23 | + | sourcePane := sourceTab.focusedPane() | |||
| 24 | + | sourcePane.stableID = "source-pane" | |||
| 25 | + | ||||
| 26 | + | releases := []func(){releaseSource} | |||
| 27 | + | destPTY, releaseDest := newBlockingPTY(t) | |||
| 28 | + | releases = append(releases, releaseDest) | |||
| 29 | + | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23}) | |||
| 30 | + | publishTiledPaneOwners(source, sourceTab) | |||
| 31 | + | destination := &session{ | |||
| 32 | + | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |||
| 33 | + | ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0, | |||
| 34 | + | } | |||
| 35 | + | publishTiledPaneOwners(destination, destinationTab) | |||
| 36 | + | for range extraDestinationTabs { | |||
| 37 | + | extraPTY, releaseExtra := newBlockingPTY(t) | |||
| 38 | + | releases = append(releases, releaseExtra) | |||
| 39 | + | extraTab := newTabWithStableID("extra-tab", "extra-pane", extraPTY, domain.Size{Cols: 80, Rows: 23}) | |||
| 40 | + | destination.tabs = append(destination.tabs, extraTab) | |||
| 41 | + | publishTiledPaneOwners(destination, extraTab) | |||
| 42 | + | } | |||
| 43 | + | d.mu.Lock() | |||
| 44 | + | d.sessions[destination.id] = destination | |||
| 45 | + | d.mu.Unlock() | |||
| 46 | + | return d, source, ac, destination, destinationTab, releases | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) { | |||
| 50 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |||
| 51 | + | defer releaseAll(releases) | |||
| 52 | + | ||||
| 53 | + | d.handleInput(source, ac, []byte("\x1b ")) | |||
| 54 | + | require.True(t, ac.overlays.paletteActive()) | |||
| 55 | + | d.handleInput(source, ac, []byte("MPN\r")) | |||
| 56 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 57 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 58 | + | ||||
| 59 | + | ac.overlays.pickerMu.Lock() | |||
| 60 | + | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource | |||
| 61 | + | ac.overlays.pickerMu.Unlock() | |||
| 62 | + | require.Equal(t, pickerMovePane, intent) | |||
| 63 | + | require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session) | |||
| 64 | + | require.Equal(t, domain.TabStableID("source-tab"), captured.TabID) | |||
| 65 | + | require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID) | |||
| 66 | + | require.Same(t, ac, captured.Client) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) { | |||
| 70 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |||
| 71 | + | defer releaseAll(releases) | |||
| 72 | + | sourceTab := source.tabs[0] | |||
| 73 | + | sourceTab.stableID = "active-tab" | |||
| 74 | + | ||||
| 75 | + | d.handleInput(source, ac, []byte("\x1b ")) | |||
| 76 | + | d.handleInput(source, ac, []byte("MTB\r")) | |||
| 77 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 78 | + | ||||
| 79 | + | ac.overlays.pickerMu.Lock() | |||
| 80 | + | intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource | |||
| 81 | + | ac.overlays.pickerMu.Unlock() | |||
| 82 | + | require.Equal(t, pickerMoveTab, intent) | |||
| 83 | + | require.Equal(t, domain.TabStableID("active-tab"), captured.TabID) | |||
| 84 | + | require.Equal(t, domain.PaneStableID(""), captured.PaneID) | |||
| 85 | + | require.Same(t, ac, captured.Client) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) { | |||
| 89 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |||
| 90 | + | defer releaseAll(releases) | |||
| 91 | + | d.mu.Lock() | |||
| 92 | + | delete(d.sessions, domain.SessionID("destination")) | |||
| 93 | + | d.mu.Unlock() | |||
| 94 | + | ||||
| 95 | + | d.handleInput(source, ac, []byte("\x1b ")) | |||
| 96 | + | d.handleInput(source, ac, []byte("MPN\r")) | |||
| 97 | + | ||||
| 98 | + | require.False(t, ac.overlays.pickerActive()) | |||
| 99 | + | history := d.notices.history() | |||
| 100 | + | require.NotEmpty(t, history) | |||
| 101 | + | require.Equal(t, "No destination available.", history[0].Message) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestMovePickerEnterCommitsMovePane(t *testing.T) { | |||
| 105 | + | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |||
| 106 | + | defer releaseAll(releases) | |||
| 107 | + | source.mu.Lock() | |||
| 108 | + | source.client = nil | |||
| 109 | + | source.mu.Unlock() | |||
| 110 | + | sourceTab := source.tabs[0] | |||
| 111 | + | moved := sourceTab.focusedPane() | |||
| 112 | + | ||||
| 113 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |||
| 114 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 115 | + | TabID: "source-tab", PaneID: "source-pane", | |||
| 116 | + | })) | |||
| 117 | + | d.handlePickerInput(ac, []byte("\r")) | |||
| 118 | + | ||||
| 119 | + | require.False(t, ac.overlays.pickerActive()) | |||
| 120 | + | require.Nil(t, source.tabs) | |||
| 121 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |||
| 122 | + | require.Same(t, destination, moved.ownerSnapshot().session) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) { | |||
| 126 | + | d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0) | |||
| 127 | + | defer releaseAll(releases) | |||
| 128 | + | source.mu.Lock() | |||
| 129 | + | source.client = nil | |||
| 130 | + | source.mu.Unlock() | |||
| 131 | + | sourceTab := source.tabs[0] | |||
| 132 | + | moved := sourceTab.focusedPane() | |||
| 133 | + | ||||
| 134 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |||
| 135 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 136 | + | TabID: "source-tab", PaneID: "source-pane", | |||
| 137 | + | })) | |||
| 138 | + | ac.overlays.pickerMu.Lock() | |||
| 139 | + | target, ok := ac.overlays.picker.Selected() | |||
| 140 | + | ac.overlays.pickerMu.Unlock() | |||
| 141 | + | require.True(t, ok) | |||
| 142 | + | require.NoError(t, d.commitMovePickerSelection(pickerMovePane, moveSourceLocator{ | |||
| 143 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 144 | + | TabID: "source-tab", PaneID: "source-pane", | |||
| 145 | + | }, target)) | |||
| 146 | + | ||||
| 147 | + | require.Nil(t, source.tabs) | |||
| 148 | + | require.Same(t, moved, destinationTab.panes[moved.id]) | |||
| 149 | + | require.Same(t, destination, moved.ownerSnapshot().session) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestMovePickerEnterCommitsMoveTab(t *testing.T) { | |||
| 153 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |||
| 154 | + | defer releaseAll(releases) | |||
| 155 | + | source.mu.Lock() | |||
| 156 | + | source.client = nil | |||
| 157 | + | source.mu.Unlock() | |||
| 158 | + | movedTab := source.tabs[0] | |||
| 159 | + | movedTab.stableID = "moved-tab" | |||
| 160 | + | ||||
| 161 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{ | |||
| 162 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 163 | + | TabID: "moved-tab", | |||
| 164 | + | })) | |||
| 165 | + | d.handlePickerInput(ac, []byte("\r")) | |||
| 166 | + | ||||
| 167 | + | require.False(t, ac.overlays.pickerActive()) | |||
| 168 | + | require.Nil(t, source.tabs) | |||
| 169 | + | require.Len(t, destination.tabs, 2) | |||
| 170 | + | require.Same(t, movedTab, destination.tabs[1]) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestMovePickerEscapePerformsNoMutation(t *testing.T) { | |||
| 174 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |||
| 175 | + | defer releaseAll(releases) | |||
| 176 | + | before := len(source.tabs) | |||
| 177 | + | ||||
| 178 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |||
| 179 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 180 | + | TabID: "source-tab", PaneID: "source-pane", | |||
| 181 | + | })) | |||
| 182 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 183 | + | d.clock = clk | |||
| 184 | + | d.handlePickerInput(ac, []byte("\x1b")) | |||
| 185 | + | timer := <-clk.timers | |||
| 186 | + | timer.ch <- time.Now() | |||
| 187 | + | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | |||
| 188 | + | require.Len(t, source.tabs, before) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) { | |||
| 192 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |||
| 193 | + | defer releaseAll(releases) | |||
| 194 | + | ||||
| 195 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |||
| 196 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 197 | + | TabID: "source-tab", PaneID: "source-pane", | |||
| 198 | + | })) | |||
| 199 | + | d.mu.Lock() | |||
| 200 | + | delete(d.sessions, destination.id) | |||
| 201 | + | d.mu.Unlock() | |||
| 202 | + | ||||
| 203 | + | d.handlePickerInput(ac, []byte("\r")) | |||
| 204 | + | ||||
| 205 | + | require.False(t, ac.overlays.pickerActive()) | |||
| 206 | + | history := d.notices.history() | |||
| 207 | + | require.NotEmpty(t, history) | |||
| 208 | + | require.Equal(t, "Destination is no longer available.", history[0].Message) | |||
| 209 | + | require.Len(t, source.tabs, 1) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) { | |||
| 213 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |||
| 214 | + | defer releaseAll(releases) | |||
| 215 | + | sourceTab := source.tabs[0] | |||
| 216 | + | ||||
| 217 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |||
| 218 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 219 | + | TabID: "source-tab", PaneID: "source-pane", | |||
| 220 | + | })) | |||
| 221 | + | sourceTab.mu.Lock() | |||
| 222 | + | delete(sourceTab.panes, sourceTab.tree.Focus) | |||
| 223 | + | sourceTab.mu.Unlock() | |||
| 224 | + | ||||
| 225 | + | d.handlePickerInput(ac, []byte("\r")) | |||
| 226 | + | ||||
| 227 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 228 | + | history := d.notices.history() | |||
| 229 | + | require.NotEmpty(t, history) | |||
| 230 | + | require.Equal(t, "Pane no longer exists.", history[0].Message) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) { | |||
| 234 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |||
| 235 | + | defer releaseAll(releases) | |||
| 236 | + | ||||
| 237 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{ | |||
| 238 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 239 | + | TabID: "source-tab", | |||
| 240 | + | })) | |||
| 241 | + | source.mu.Lock() | |||
| 242 | + | source.tabs = nil | |||
| 243 | + | source.mu.Unlock() | |||
| 244 | + | ||||
| 245 | + | d.handlePickerInput(ac, []byte("\r")) | |||
| 246 | + | ||||
| 247 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 248 | + | history := d.notices.history() | |||
| 249 | + | require.NotEmpty(t, history) | |||
| 250 | + | require.Equal(t, "Tab no longer exists.", history[0].Message) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) { | |||
| 254 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |||
| 255 | + | defer releaseAll(releases) | |||
| 256 | + | ||||
| 257 | + | require.NoError(t, paletteExec{d: d, sess: source, ac: ac}.OpenMovePanePicker()) | |||
| 258 | + | replacement := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size} | |||
| 259 | + | replacement.initOverlays() | |||
| 260 | + | replacement.setSession(source) | |||
| 261 | + | source.mu.Lock() | |||
| 262 | + | source.client = replacement | |||
| 263 | + | source.mu.Unlock() | |||
| 264 | + | ||||
| 265 | + | d.handlePickerInput(ac, []byte("\r")) | |||
| 266 | + | ||||
| 267 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 268 | + | require.Len(t, source.tabs, 1) | |||
| 269 | + | require.Len(t, destination.tabs, 1) | |||
| 270 | + | history := d.notices.history() | |||
| 271 | + | require.NotEmpty(t, history) | |||
| 272 | + | require.Equal(t, "Source client is no longer active.", history[0].Message) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) { | |||
| 276 | + | d, source, follower, destination, _, releases := setupMovePickerSessions(t, 0) | |||
| 277 | + | defer releaseAll(releases) | |||
| 278 | + | follower.replaceTransport(&closeTrackingTransport{}) | |||
| 279 | + | require.NotNil(t, d.attachCoordinator(source, nil, follower, true)) | |||
| 280 | + | ||||
| 281 | + | displacedTransport := &closeTrackingTransport{} | |||
| 282 | + | displaced := &attachedClient{tr: displacedTransport, output: newOutputStateStream(), size: follower.size} | |||
| 283 | + | displaced.initOverlays() | |||
| 284 | + | displaced.setSession(destination) | |||
| 285 | + | destination.mu.Lock() | |||
| 286 | + | destination.client = displaced | |||
| 287 | + | destination.mu.Unlock() | |||
| 288 | + | require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true)) | |||
| 289 | + | ||||
| 290 | + | var pickerClosed atomic.Bool | |||
| 291 | + | d.afterDisplacedCleanupStarted = func() { | |||
| 292 | + | require.False(t, follower.overlays.pickerActive(), "picker must close before snatch finalization") | |||
| 293 | + | pickerClosed.Store(true) | |||
| 294 | + | } | |||
| 295 | + | defer func() { d.afterDisplacedCleanupStarted = nil }() | |||
| 296 | + | ||||
| 297 | + | require.NoError(t, d.enterPickerForIntent(source, follower, pickerMovePane, moveSourceLocator{ | |||
| 298 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 299 | + | TabID: "source-tab", PaneID: "source-pane", | |||
| 300 | + | })) | |||
| 301 | + | d.handlePickerInput(follower, []byte("\r")) | |||
| 302 | + | d.attachmentCleanupWg.Wait() | |||
| 303 | + | ||||
| 304 | + | require.True(t, pickerClosed.Load()) | |||
| 305 | + | require.False(t, follower.overlays.pickerActive()) | |||
| 306 | + | frames := displacedTransport.Sends() | |||
| 307 | + | require.NotEmpty(t, frames) | |||
| 308 | + | require.Equal(t, ports.MsgOutput, frames[0].Type) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) { | |||
| 312 | + | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |||
| 313 | + | defer releaseAll(releases) | |||
| 314 | + | ||||
| 315 | + | d.handleInput(source, ac, []byte("\x1b ")) | |||
| 316 | + | d.handleInput(source, ac, []byte("MPN\r")) | |||
| 317 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 318 | + | require.True(t, source.dispatchMu.TryLock(), "dispatchMu must not remain held while the move picker is open") | |||
| 319 | + | source.dispatchMu.Unlock() | |||
| 320 | + | } | |||
A internal/usecase/daemon/move_rejection.go +187 -0
| @@ -0,0 +1,187 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // moveRejectionReason is the daemon API category for a rejected move. It keeps | |
| 12 | + | // callers independent of topology and layout implementation errors. | |
| 13 | + | type moveRejectionReason uint8 | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | moveRejectionInvalid moveRejectionReason = iota + 1 | |
| 17 | + | moveRejectionNoDestination | |
| 18 | + | moveRejectionFloatingWarming | |
| 19 | + | moveRejectionFinalSourceFloating | |
| 20 | + | moveRejectionStaleTarget | |
| 21 | + | moveRejectionTooSmall | |
| 22 | + | moveRejectionPaneIDExhausted | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // moveRejection carries a stable category while Unwrap preserves identities | |
| 26 | + | // relied on by lower-level move tests and callers. | |
| 27 | + | type moveRejection struct { | |
| 28 | + | Reason moveRejectionReason | |
| 29 | + | msg string | |
| 30 | + | cause error | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (e *moveRejection) Error() string { return e.msg } | |
| 34 | + | func (e *moveRejection) Unwrap() error { return e.cause } | |
| 35 | + | ||
| 36 | + | // moveRejectionDescriptor is the canonical presentation for one rejection | |
| 37 | + | // reason. Palette and control adapters only wrap these values for their | |
| 38 | + | // respective transports. | |
| 39 | + | type moveRejectionDescriptor struct { | |
| 40 | + | Reason moveRejectionReason | |
| 41 | + | PaletteCode domain.NoticeCode | |
| 42 | + | PaletteSeverity domain.NoticeSeverity | |
| 43 | + | PaletteText string | |
| 44 | + | CommandCode uint16 | |
| 45 | + | CommandText string | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func moveRejectionDescriptorFor(err error) (moveRejectionDescriptor, bool) { | |
| 49 | + | reason, ok := moveRejectionReasonFor(err) | |
| 50 | + | if !ok { | |
| 51 | + | return moveRejectionDescriptor{}, false | |
| 52 | + | } | |
| 53 | + | return moveRejectionDescriptorByReason(reason), true | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func moveRejectionReasonFor(err error) (moveRejectionReason, bool) { | |
| 57 | + | var rejection *moveRejection | |
| 58 | + | if errors.As(err, &rejection) { | |
| 59 | + | return rejection.Reason, true | |
| 60 | + | } | |
| 61 | + | if errors.Is(err, errMoveLifecycleUnavailable) { | |
| 62 | + | return moveRejectionStaleTarget, true | |
| 63 | + | } | |
| 64 | + | return 0, false | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func moveRejectionDescriptorByReason(reason moveRejectionReason) moveRejectionDescriptor { | |
| 68 | + | descriptor := moveRejectionDescriptor{ | |
| 69 | + | Reason: reason, | |
| 70 | + | PaletteCode: domain.NoticeInternal, | |
| 71 | + | PaletteSeverity: domain.NoticeError, | |
| 72 | + | PaletteText: "Move failed.", | |
| 73 | + | CommandCode: ports.ErrNoSuchTarget, | |
| 74 | + | CommandText: "move request is invalid", | |
| 75 | + | } | |
| 76 | + | switch reason { | |
| 77 | + | case moveRejectionNoDestination: | |
| 78 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |
| 79 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 80 | + | descriptor.PaletteText = "No destination available." | |
| 81 | + | descriptor.CommandText = "no move destination available" | |
| 82 | + | case moveRejectionFloatingWarming: | |
| 83 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |
| 84 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 85 | + | descriptor.PaletteText = "Wait for the floating pane to finish opening." | |
| 86 | + | descriptor.CommandText = "wait for the floating pane to finish opening" | |
| 87 | + | case moveRejectionFinalSourceFloating: | |
| 88 | + | descriptor.PaletteCode = domain.NoticeLayoutTooSmall | |
| 89 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 90 | + | descriptor.PaletteText = "Close the floating pane or move the whole tab." | |
| 91 | + | descriptor.CommandText = "close the floating pane or move the whole tab" | |
| 92 | + | case moveRejectionStaleTarget: | |
| 93 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |
| 94 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 95 | + | descriptor.PaletteText = "Destination is no longer available." | |
| 96 | + | descriptor.CommandText = "move target is no longer available" | |
| 97 | + | case moveRejectionTooSmall: | |
| 98 | + | descriptor.PaletteCode = domain.NoticeLayoutTooSmall | |
| 99 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |
| 100 | + | descriptor.PaletteText = "Not enough space in destination tab." | |
| 101 | + | descriptor.CommandText = "not enough space in destination tab" | |
| 102 | + | case moveRejectionPaneIDExhausted: | |
| 103 | + | descriptor.PaletteText = "Destination pane IDs are exhausted." | |
| 104 | + | descriptor.CommandText = "destination pane IDs are exhausted" | |
| 105 | + | } | |
| 106 | + | return descriptor | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func movePickerUserError(err error) error { | |
| 110 | + | if err == nil { | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | var userErr *domain.UserError | |
| 114 | + | if errors.As(err, &userErr) { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | descriptor, ok := moveRejectionDescriptorFor(err) | |
| 118 | + | if !ok { | |
| 119 | + | descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid) | |
| 120 | + | } | |
| 121 | + | if descriptor.PaletteSeverity == domain.NoticeWarn { | |
| 122 | + | return domain.UserWarn(descriptor.PaletteCode, descriptor.PaletteText, err) | |
| 123 | + | } | |
| 124 | + | return domain.UserErr(descriptor.PaletteCode, descriptor.PaletteText, err) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func isMoveCommandError(err error) bool { | |
| 128 | + | _, ok := moveRejectionReasonFor(err) | |
| 129 | + | return ok | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func moveCommandFailure(err error) ports.CommandResult { | |
| 133 | + | descriptor, ok := moveRejectionDescriptorFor(err) | |
| 134 | + | if !ok { | |
| 135 | + | descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid) | |
| 136 | + | } | |
| 137 | + | return commandFailure(descriptor.CommandCode, descriptor.CommandText) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func normalizeMoveRejection(err error) error { | |
| 141 | + | if err == nil { | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | var rejection *moveRejection | |
| 145 | + | if errors.As(err, &rejection) { | |
| 146 | + | return err | |
| 147 | + | } | |
| 148 | + | if errors.Is(err, layout.ErrTooSmall) { | |
| 149 | + | return errMoveTooSmall | |
| 150 | + | } | |
| 151 | + | if errors.Is(err, errMoveLifecycleUnavailable) { | |
| 152 | + | return &moveRejection{Reason: moveRejectionStaleTarget, msg: errMoveStaleTarget.msg, cause: errors.Join(errMovePaneInvalid, err)} | |
| 153 | + | } | |
| 154 | + | return &moveRejection{Reason: moveRejectionInvalid, msg: errMovePaneInvalid.msg, cause: errors.Join(errMovePaneInvalid, err)} | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | var ( | |
| 158 | + | errMovePaneInvalid = &moveRejection{Reason: moveRejectionInvalid, msg: "move request is invalid"} | |
| 159 | + | errNoMoveDestination = &moveRejection{ | |
| 160 | + | Reason: moveRejectionNoDestination, | |
| 161 | + | msg: "no eligible move destination", | |
| 162 | + | } | |
| 163 | + | errMoveFloatingWarming = &moveRejection{ | |
| 164 | + | Reason: moveRejectionFloatingWarming, | |
| 165 | + | msg: "floating pane is still opening", | |
| 166 | + | } | |
| 167 | + | errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane") | |
| 168 | + | errMoveFinalSourceFloating = &moveRejection{ | |
| 169 | + | Reason: moveRejectionFinalSourceFloating, | |
| 170 | + | msg: "final source pane cannot move while its tab has a floating pane", | |
| 171 | + | cause: errMovePaneFloatingSibling, | |
| 172 | + | } | |
| 173 | + | errMoveStaleTarget = &moveRejection{ | |
| 174 | + | Reason: moveRejectionStaleTarget, | |
| 175 | + | msg: "move target is no longer available", | |
| 176 | + | cause: errMovePaneInvalid, | |
| 177 | + | } | |
| 178 | + | errMoveTooSmall = &moveRejection{ | |
| 179 | + | Reason: moveRejectionTooSmall, | |
| 180 | + | msg: "move destination is too small", | |
| 181 | + | cause: layout.ErrTooSmall, | |
| 182 | + | } | |
| 183 | + | errMovePaneIDExhausted = &moveRejection{ | |
| 184 | + | Reason: moveRejectionPaneIDExhausted, | |
| 185 | + | msg: "destination pane IDs exhausted", | |
| 186 | + | } | |
| 187 | + | ) | |
| @@ -0,0 +1,187 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // moveRejectionReason is the daemon API category for a rejected move. It keeps | |||
| 12 | + | // callers independent of topology and layout implementation errors. | |||
| 13 | + | type moveRejectionReason uint8 | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | moveRejectionInvalid moveRejectionReason = iota + 1 | |||
| 17 | + | moveRejectionNoDestination | |||
| 18 | + | moveRejectionFloatingWarming | |||
| 19 | + | moveRejectionFinalSourceFloating | |||
| 20 | + | moveRejectionStaleTarget | |||
| 21 | + | moveRejectionTooSmall | |||
| 22 | + | moveRejectionPaneIDExhausted | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // moveRejection carries a stable category while Unwrap preserves identities | |||
| 26 | + | // relied on by lower-level move tests and callers. | |||
| 27 | + | type moveRejection struct { | |||
| 28 | + | Reason moveRejectionReason | |||
| 29 | + | msg string | |||
| 30 | + | cause error | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (e *moveRejection) Error() string { return e.msg } | |||
| 34 | + | func (e *moveRejection) Unwrap() error { return e.cause } | |||
| 35 | + | ||||
| 36 | + | // moveRejectionDescriptor is the canonical presentation for one rejection | |||
| 37 | + | // reason. Palette and control adapters only wrap these values for their | |||
| 38 | + | // respective transports. | |||
| 39 | + | type moveRejectionDescriptor struct { | |||
| 40 | + | Reason moveRejectionReason | |||
| 41 | + | PaletteCode domain.NoticeCode | |||
| 42 | + | PaletteSeverity domain.NoticeSeverity | |||
| 43 | + | PaletteText string | |||
| 44 | + | CommandCode uint16 | |||
| 45 | + | CommandText string | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func moveRejectionDescriptorFor(err error) (moveRejectionDescriptor, bool) { | |||
| 49 | + | reason, ok := moveRejectionReasonFor(err) | |||
| 50 | + | if !ok { | |||
| 51 | + | return moveRejectionDescriptor{}, false | |||
| 52 | + | } | |||
| 53 | + | return moveRejectionDescriptorByReason(reason), true | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func moveRejectionReasonFor(err error) (moveRejectionReason, bool) { | |||
| 57 | + | var rejection *moveRejection | |||
| 58 | + | if errors.As(err, &rejection) { | |||
| 59 | + | return rejection.Reason, true | |||
| 60 | + | } | |||
| 61 | + | if errors.Is(err, errMoveLifecycleUnavailable) { | |||
| 62 | + | return moveRejectionStaleTarget, true | |||
| 63 | + | } | |||
| 64 | + | return 0, false | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func moveRejectionDescriptorByReason(reason moveRejectionReason) moveRejectionDescriptor { | |||
| 68 | + | descriptor := moveRejectionDescriptor{ | |||
| 69 | + | Reason: reason, | |||
| 70 | + | PaletteCode: domain.NoticeInternal, | |||
| 71 | + | PaletteSeverity: domain.NoticeError, | |||
| 72 | + | PaletteText: "Move failed.", | |||
| 73 | + | CommandCode: ports.ErrNoSuchTarget, | |||
| 74 | + | CommandText: "move request is invalid", | |||
| 75 | + | } | |||
| 76 | + | switch reason { | |||
| 77 | + | case moveRejectionNoDestination: | |||
| 78 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |||
| 79 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |||
| 80 | + | descriptor.PaletteText = "No destination available." | |||
| 81 | + | descriptor.CommandText = "no move destination available" | |||
| 82 | + | case moveRejectionFloatingWarming: | |||
| 83 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |||
| 84 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |||
| 85 | + | descriptor.PaletteText = "Wait for the floating pane to finish opening." | |||
| 86 | + | descriptor.CommandText = "wait for the floating pane to finish opening" | |||
| 87 | + | case moveRejectionFinalSourceFloating: | |||
| 88 | + | descriptor.PaletteCode = domain.NoticeLayoutTooSmall | |||
| 89 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |||
| 90 | + | descriptor.PaletteText = "Close the floating pane or move the whole tab." | |||
| 91 | + | descriptor.CommandText = "close the floating pane or move the whole tab" | |||
| 92 | + | case moveRejectionStaleTarget: | |||
| 93 | + | descriptor.PaletteCode = domain.NoticeSessionUnavailable | |||
| 94 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |||
| 95 | + | descriptor.PaletteText = "Destination is no longer available." | |||
| 96 | + | descriptor.CommandText = "move target is no longer available" | |||
| 97 | + | case moveRejectionTooSmall: | |||
| 98 | + | descriptor.PaletteCode = domain.NoticeLayoutTooSmall | |||
| 99 | + | descriptor.PaletteSeverity = domain.NoticeWarn | |||
| 100 | + | descriptor.PaletteText = "Not enough space in destination tab." | |||
| 101 | + | descriptor.CommandText = "not enough space in destination tab" | |||
| 102 | + | case moveRejectionPaneIDExhausted: | |||
| 103 | + | descriptor.PaletteText = "Destination pane IDs are exhausted." | |||
| 104 | + | descriptor.CommandText = "destination pane IDs are exhausted" | |||
| 105 | + | } | |||
| 106 | + | return descriptor | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func movePickerUserError(err error) error { | |||
| 110 | + | if err == nil { | |||
| 111 | + | return nil | |||
| 112 | + | } | |||
| 113 | + | var userErr *domain.UserError | |||
| 114 | + | if errors.As(err, &userErr) { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | descriptor, ok := moveRejectionDescriptorFor(err) | |||
| 118 | + | if !ok { | |||
| 119 | + | descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid) | |||
| 120 | + | } | |||
| 121 | + | if descriptor.PaletteSeverity == domain.NoticeWarn { | |||
| 122 | + | return domain.UserWarn(descriptor.PaletteCode, descriptor.PaletteText, err) | |||
| 123 | + | } | |||
| 124 | + | return domain.UserErr(descriptor.PaletteCode, descriptor.PaletteText, err) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func isMoveCommandError(err error) bool { | |||
| 128 | + | _, ok := moveRejectionReasonFor(err) | |||
| 129 | + | return ok | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func moveCommandFailure(err error) ports.CommandResult { | |||
| 133 | + | descriptor, ok := moveRejectionDescriptorFor(err) | |||
| 134 | + | if !ok { | |||
| 135 | + | descriptor = moveRejectionDescriptorByReason(moveRejectionInvalid) | |||
| 136 | + | } | |||
| 137 | + | return commandFailure(descriptor.CommandCode, descriptor.CommandText) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func normalizeMoveRejection(err error) error { | |||
| 141 | + | if err == nil { | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | var rejection *moveRejection | |||
| 145 | + | if errors.As(err, &rejection) { | |||
| 146 | + | return err | |||
| 147 | + | } | |||
| 148 | + | if errors.Is(err, layout.ErrTooSmall) { | |||
| 149 | + | return errMoveTooSmall | |||
| 150 | + | } | |||
| 151 | + | if errors.Is(err, errMoveLifecycleUnavailable) { | |||
| 152 | + | return &moveRejection{Reason: moveRejectionStaleTarget, msg: errMoveStaleTarget.msg, cause: errors.Join(errMovePaneInvalid, err)} | |||
| 153 | + | } | |||
| 154 | + | return &moveRejection{Reason: moveRejectionInvalid, msg: errMovePaneInvalid.msg, cause: errors.Join(errMovePaneInvalid, err)} | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | var ( | |||
| 158 | + | errMovePaneInvalid = &moveRejection{Reason: moveRejectionInvalid, msg: "move request is invalid"} | |||
| 159 | + | errNoMoveDestination = &moveRejection{ | |||
| 160 | + | Reason: moveRejectionNoDestination, | |||
| 161 | + | msg: "no eligible move destination", | |||
| 162 | + | } | |||
| 163 | + | errMoveFloatingWarming = &moveRejection{ | |||
| 164 | + | Reason: moveRejectionFloatingWarming, | |||
| 165 | + | msg: "floating pane is still opening", | |||
| 166 | + | } | |||
| 167 | + | errMovePaneFloatingSibling = errors.New("cannot remove a source tab with a floating pane") | |||
| 168 | + | errMoveFinalSourceFloating = &moveRejection{ | |||
| 169 | + | Reason: moveRejectionFinalSourceFloating, | |||
| 170 | + | msg: "final source pane cannot move while its tab has a floating pane", | |||
| 171 | + | cause: errMovePaneFloatingSibling, | |||
| 172 | + | } | |||
| 173 | + | errMoveStaleTarget = &moveRejection{ | |||
| 174 | + | Reason: moveRejectionStaleTarget, | |||
| 175 | + | msg: "move target is no longer available", | |||
| 176 | + | cause: errMovePaneInvalid, | |||
| 177 | + | } | |||
| 178 | + | errMoveTooSmall = &moveRejection{ | |||
| 179 | + | Reason: moveRejectionTooSmall, | |||
| 180 | + | msg: "move destination is too small", | |||
| 181 | + | cause: layout.ErrTooSmall, | |||
| 182 | + | } | |||
| 183 | + | errMovePaneIDExhausted = &moveRejection{ | |||
| 184 | + | Reason: moveRejectionPaneIDExhausted, | |||
| 185 | + | msg: "destination pane IDs exhausted", | |||
| 186 | + | } | |||
| 187 | + | ) | |||
A internal/usecase/daemon/move_rejection_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestMoveRejectionCategoriesPreserveLegacyIdentity(t *testing.T) { | |
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | err error | |
| 18 | + | reason moveRejectionReason | |
| 19 | + | legacy error | |
| 20 | + | }{ | |
| 21 | + | {name: "no destination", err: errNoMoveDestination, reason: moveRejectionNoDestination}, | |
| 22 | + | {name: "warming", err: errMoveFloatingWarming, reason: moveRejectionFloatingWarming}, | |
| 23 | + | {name: "installed final source floating", err: errMoveFinalSourceFloating, reason: moveRejectionFinalSourceFloating, legacy: errMovePaneFloatingSibling}, | |
| 24 | + | {name: "stale target", err: errMoveStaleTarget, reason: moveRejectionStaleTarget, legacy: errMovePaneInvalid}, | |
| 25 | + | {name: "too small", err: errMoveTooSmall, reason: moveRejectionTooSmall, legacy: layout.ErrTooSmall}, | |
| 26 | + | {name: "pane ID exhaustion", err: errMovePaneIDExhausted, reason: moveRejectionPaneIDExhausted}, | |
| 27 | + | {name: "generic invalid", err: errMovePaneInvalid, reason: moveRejectionInvalid}, | |
| 28 | + | } | |
| 29 | + | for _, tt := range tests { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | var rejection *moveRejection | |
| 32 | + | require.ErrorAs(t, tt.err, &rejection) | |
| 33 | + | require.Equal(t, tt.reason, rejection.Reason) | |
| 34 | + | if tt.legacy != nil { | |
| 35 | + | require.True(t, errors.Is(tt.err, tt.legacy)) | |
| 36 | + | } | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | require.NotErrorIs(t, errMoveFloatingWarming, errMoveFinalSourceFloating) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestNormalizeMoveRejectionDoesNotLeakInternalErrors(t *testing.T) { | |
| 43 | + | internal := errors.New("layout implementation detail") | |
| 44 | + | got := normalizeMoveRejection(internal) | |
| 45 | + | var rejection *moveRejection | |
| 46 | + | require.ErrorAs(t, got, &rejection) | |
| 47 | + | require.Equal(t, moveRejectionInvalid, rejection.Reason) | |
| 48 | + | require.Equal(t, "move request is invalid", got.Error()) | |
| 49 | + | require.ErrorIs(t, got, internal) | |
| 50 | + | require.ErrorIs(t, got, errMovePaneInvalid) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestMoveRejectionPresentationParity(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | err error | |
| 57 | + | reason moveRejectionReason | |
| 58 | + | paletteCode domain.NoticeCode | |
| 59 | + | paletteSeverity domain.NoticeSeverity | |
| 60 | + | paletteText string | |
| 61 | + | commandText string | |
| 62 | + | }{ | |
| 63 | + | {name: "no destination", err: errNoMoveDestination, reason: moveRejectionNoDestination, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "No destination available.", commandText: "no move destination available"}, | |
| 64 | + | {name: "warming", err: errMoveFloatingWarming, reason: moveRejectionFloatingWarming, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Wait for the floating pane to finish opening.", commandText: "wait for the floating pane to finish opening"}, | |
| 65 | + | {name: "installed final source floating", err: errMoveFinalSourceFloating, reason: moveRejectionFinalSourceFloating, paletteCode: domain.NoticeLayoutTooSmall, paletteSeverity: domain.NoticeWarn, paletteText: "Close the floating pane or move the whole tab.", commandText: "close the floating pane or move the whole tab"}, | |
| 66 | + | {name: "too small", err: errMoveTooSmall, reason: moveRejectionTooSmall, paletteCode: domain.NoticeLayoutTooSmall, paletteSeverity: domain.NoticeWarn, paletteText: "Not enough space in destination tab.", commandText: "not enough space in destination tab"}, | |
| 67 | + | {name: "stale", err: errMoveStaleTarget, reason: moveRejectionStaleTarget, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Destination is no longer available.", commandText: "move target is no longer available"}, | |
| 68 | + | {name: "lifecycle target", err: errMoveLifecycleUnavailable, reason: moveRejectionStaleTarget, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Destination is no longer available.", commandText: "move target is no longer available"}, | |
| 69 | + | {name: "pane ID exhaustion", err: errMovePaneIDExhausted, reason: moveRejectionPaneIDExhausted, paletteCode: domain.NoticeInternal, paletteSeverity: domain.NoticeError, paletteText: "Destination pane IDs are exhausted.", commandText: "destination pane IDs are exhausted"}, | |
| 70 | + | {name: "generic invalid", err: errMovePaneInvalid, reason: moveRejectionInvalid, paletteCode: domain.NoticeInternal, paletteSeverity: domain.NoticeError, paletteText: "Move failed.", commandText: "move request is invalid"}, | |
| 71 | + | } | |
| 72 | + | for _, tt := range tests { | |
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |
| 74 | + | descriptor, ok := moveRejectionDescriptorFor(tt.err) | |
| 75 | + | require.True(t, ok) | |
| 76 | + | require.Equal(t, tt.reason, descriptor.Reason) | |
| 77 | + | ||
| 78 | + | paletteErr := movePickerUserError(tt.err) | |
| 79 | + | var userErr *domain.UserError | |
| 80 | + | require.ErrorAs(t, paletteErr, &userErr) | |
| 81 | + | require.Equal(t, descriptor.PaletteCode, userErr.Code) | |
| 82 | + | require.Equal(t, descriptor.PaletteSeverity, userErr.Severity) | |
| 83 | + | require.Equal(t, descriptor.PaletteText, userErr.Msg) | |
| 84 | + | require.Equal(t, tt.paletteCode, userErr.Code) | |
| 85 | + | require.Equal(t, tt.paletteSeverity, userErr.Severity) | |
| 86 | + | require.Equal(t, tt.paletteText, userErr.Msg) | |
| 87 | + | ||
| 88 | + | result := moveCommandFailure(tt.err) | |
| 89 | + | require.False(t, result.OK) | |
| 90 | + | require.Equal(t, descriptor.CommandCode, result.Code) | |
| 91 | + | require.Equal(t, descriptor.CommandText, result.Text) | |
| 92 | + | require.Equal(t, uint16(ports.ErrNoSuchTarget), result.Code) | |
| 93 | + | require.Equal(t, tt.commandText, result.Text) | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestMoveRejectionCategoriesPreserveLegacyIdentity(t *testing.T) { | |||
| 15 | + | tests := []struct { | |||
| 16 | + | name string | |||
| 17 | + | err error | |||
| 18 | + | reason moveRejectionReason | |||
| 19 | + | legacy error | |||
| 20 | + | }{ | |||
| 21 | + | {name: "no destination", err: errNoMoveDestination, reason: moveRejectionNoDestination}, | |||
| 22 | + | {name: "warming", err: errMoveFloatingWarming, reason: moveRejectionFloatingWarming}, | |||
| 23 | + | {name: "installed final source floating", err: errMoveFinalSourceFloating, reason: moveRejectionFinalSourceFloating, legacy: errMovePaneFloatingSibling}, | |||
| 24 | + | {name: "stale target", err: errMoveStaleTarget, reason: moveRejectionStaleTarget, legacy: errMovePaneInvalid}, | |||
| 25 | + | {name: "too small", err: errMoveTooSmall, reason: moveRejectionTooSmall, legacy: layout.ErrTooSmall}, | |||
| 26 | + | {name: "pane ID exhaustion", err: errMovePaneIDExhausted, reason: moveRejectionPaneIDExhausted}, | |||
| 27 | + | {name: "generic invalid", err: errMovePaneInvalid, reason: moveRejectionInvalid}, | |||
| 28 | + | } | |||
| 29 | + | for _, tt := range tests { | |||
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | + | var rejection *moveRejection | |||
| 32 | + | require.ErrorAs(t, tt.err, &rejection) | |||
| 33 | + | require.Equal(t, tt.reason, rejection.Reason) | |||
| 34 | + | if tt.legacy != nil { | |||
| 35 | + | require.True(t, errors.Is(tt.err, tt.legacy)) | |||
| 36 | + | } | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | require.NotErrorIs(t, errMoveFloatingWarming, errMoveFinalSourceFloating) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestNormalizeMoveRejectionDoesNotLeakInternalErrors(t *testing.T) { | |||
| 43 | + | internal := errors.New("layout implementation detail") | |||
| 44 | + | got := normalizeMoveRejection(internal) | |||
| 45 | + | var rejection *moveRejection | |||
| 46 | + | require.ErrorAs(t, got, &rejection) | |||
| 47 | + | require.Equal(t, moveRejectionInvalid, rejection.Reason) | |||
| 48 | + | require.Equal(t, "move request is invalid", got.Error()) | |||
| 49 | + | require.ErrorIs(t, got, internal) | |||
| 50 | + | require.ErrorIs(t, got, errMovePaneInvalid) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestMoveRejectionPresentationParity(t *testing.T) { | |||
| 54 | + | tests := []struct { | |||
| 55 | + | name string | |||
| 56 | + | err error | |||
| 57 | + | reason moveRejectionReason | |||
| 58 | + | paletteCode domain.NoticeCode | |||
| 59 | + | paletteSeverity domain.NoticeSeverity | |||
| 60 | + | paletteText string | |||
| 61 | + | commandText string | |||
| 62 | + | }{ | |||
| 63 | + | {name: "no destination", err: errNoMoveDestination, reason: moveRejectionNoDestination, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "No destination available.", commandText: "no move destination available"}, | |||
| 64 | + | {name: "warming", err: errMoveFloatingWarming, reason: moveRejectionFloatingWarming, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Wait for the floating pane to finish opening.", commandText: "wait for the floating pane to finish opening"}, | |||
| 65 | + | {name: "installed final source floating", err: errMoveFinalSourceFloating, reason: moveRejectionFinalSourceFloating, paletteCode: domain.NoticeLayoutTooSmall, paletteSeverity: domain.NoticeWarn, paletteText: "Close the floating pane or move the whole tab.", commandText: "close the floating pane or move the whole tab"}, | |||
| 66 | + | {name: "too small", err: errMoveTooSmall, reason: moveRejectionTooSmall, paletteCode: domain.NoticeLayoutTooSmall, paletteSeverity: domain.NoticeWarn, paletteText: "Not enough space in destination tab.", commandText: "not enough space in destination tab"}, | |||
| 67 | + | {name: "stale", err: errMoveStaleTarget, reason: moveRejectionStaleTarget, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Destination is no longer available.", commandText: "move target is no longer available"}, | |||
| 68 | + | {name: "lifecycle target", err: errMoveLifecycleUnavailable, reason: moveRejectionStaleTarget, paletteCode: domain.NoticeSessionUnavailable, paletteSeverity: domain.NoticeWarn, paletteText: "Destination is no longer available.", commandText: "move target is no longer available"}, | |||
| 69 | + | {name: "pane ID exhaustion", err: errMovePaneIDExhausted, reason: moveRejectionPaneIDExhausted, paletteCode: domain.NoticeInternal, paletteSeverity: domain.NoticeError, paletteText: "Destination pane IDs are exhausted.", commandText: "destination pane IDs are exhausted"}, | |||
| 70 | + | {name: "generic invalid", err: errMovePaneInvalid, reason: moveRejectionInvalid, paletteCode: domain.NoticeInternal, paletteSeverity: domain.NoticeError, paletteText: "Move failed.", commandText: "move request is invalid"}, | |||
| 71 | + | } | |||
| 72 | + | for _, tt := range tests { | |||
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 74 | + | descriptor, ok := moveRejectionDescriptorFor(tt.err) | |||
| 75 | + | require.True(t, ok) | |||
| 76 | + | require.Equal(t, tt.reason, descriptor.Reason) | |||
| 77 | + | ||||
| 78 | + | paletteErr := movePickerUserError(tt.err) | |||
| 79 | + | var userErr *domain.UserError | |||
| 80 | + | require.ErrorAs(t, paletteErr, &userErr) | |||
| 81 | + | require.Equal(t, descriptor.PaletteCode, userErr.Code) | |||
| 82 | + | require.Equal(t, descriptor.PaletteSeverity, userErr.Severity) | |||
| 83 | + | require.Equal(t, descriptor.PaletteText, userErr.Msg) | |||
| 84 | + | require.Equal(t, tt.paletteCode, userErr.Code) | |||
| 85 | + | require.Equal(t, tt.paletteSeverity, userErr.Severity) | |||
| 86 | + | require.Equal(t, tt.paletteText, userErr.Msg) | |||
| 87 | + | ||||
| 88 | + | result := moveCommandFailure(tt.err) | |||
| 89 | + | require.False(t, result.OK) | |||
| 90 | + | require.Equal(t, descriptor.CommandCode, result.Code) | |||
| 91 | + | require.Equal(t, descriptor.CommandText, result.Text) | |||
| 92 | + | require.Equal(t, uint16(ports.ErrNoSuchTarget), result.Code) | |||
| 93 | + | require.Equal(t, tt.commandText, result.Text) | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
M internal/usecase/daemon/move_tab.go +13 -9
| @@ -46,7 +46,8 @@ type moveTabCommit struct { | |||
| 46 | 46 | err error | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | - | func (d *Daemon) moveTab(req moveTabRequest) error { | |
| 49 | + | func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |
| 50 | + | defer func() { result = normalizeMoveRejection(result) }() | |
| 50 | 51 | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | |
| 51 | 52 | return errMovePaneInvalid | |
| 52 | 53 | } | |
| @@ -54,12 +55,15 @@ func (d *Daemon) moveTab(req moveTabRequest) error { | |||
| 54 | 55 | source := moveSessionForLocatorLocked(d, req.Source) | |
| 55 | 56 | destination := moveSessionForLocatorLocked(d, req.Destination) | |
| 56 | 57 | d.mu.Unlock() | |
| 57 | - | if source == nil || destination == nil || source == destination { | |
| 58 | + | if source == nil || destination == nil { | |
| 59 | + | return errMoveStaleTarget | |
| 60 | + | } | |
| 61 | + | if source == destination { | |
| 58 | 62 | return errMovePaneInvalid | |
| 59 | 63 | } | |
| 60 | 64 | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 61 | 65 | if err != nil { | |
| 62 | - | return err | |
| 66 | + | return errMoveStaleTarget | |
| 63 | 67 | } | |
| 64 | 68 | reservationHeld := true | |
| 65 | 69 | defer func() { | |
| @@ -156,7 +160,7 @@ func (d *Daemon) moveTab(req moveTabRequest) error { | |||
| 156 | 160 | if commit.err != nil { | |
| 157 | 161 | return commit.err | |
| 158 | 162 | } | |
| 159 | - | return errMovePaneInvalid | |
| 163 | + | return errMoveStaleTarget | |
| 160 | 164 | } | |
| 161 | 165 | fences.Release() | |
| 162 | 166 | if commit.oldTabCancel != nil { | |
| @@ -232,16 +236,16 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 232 | 236 | d.mu.Lock() | |
| 233 | 237 | defer d.mu.Unlock() | |
| 234 | 238 | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | |
| 235 | - | return nil, errMovePaneInvalid | |
| 239 | + | return nil, errMoveStaleTarget | |
| 236 | 240 | } | |
| 237 | 241 | unlockSessions := lockAttachmentSessions(source, destination) | |
| 238 | 242 | defer unlockSessions() | |
| 239 | 243 | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | |
| 240 | - | return nil, errMovePaneInvalid | |
| 244 | + | return nil, errMoveStaleTarget | |
| 241 | 245 | } | |
| 242 | 246 | moved := findMoveTabLocked(source, req.SourceTabID) | |
| 243 | 247 | if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) { | |
| 244 | - | return nil, errMovePaneInvalid | |
| 248 | + | return nil, errMoveStaleTarget | |
| 245 | 249 | } | |
| 246 | 250 | destinationActive := destination.tabs[destination.active] | |
| 247 | 251 | destinationActive.mu.Lock() | |
| @@ -250,10 +254,10 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 250 | 254 | moved.mu.Lock() | |
| 251 | 255 | defer moved.mu.Unlock() | |
| 252 | 256 | if !moved.floatingTransferableLocked() { | |
| 253 | - | return nil, errMovePaneInvalid | |
| 257 | + | return nil, errMoveFloatingWarming | |
| 254 | 258 | } | |
| 255 | 259 | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | |
| 256 | - | return nil, errMovePaneInvalid | |
| 260 | + | return nil, errMoveTooSmall | |
| 257 | 261 | } | |
| 258 | 262 | panes := make([]*pane, 0, len(moved.panes)+1) | |
| 259 | 263 | for _, p := range moved.panes { | |
| @@ -46,7 +46,8 @@ type moveTabCommit struct { | |||||
| 46 | err error | 46 | err error | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | - | func (d *Daemon) moveTab(req moveTabRequest) error { | 49 | + | func (d *Daemon) moveTab(req moveTabRequest) (result error) { |
| 50 | + | defer func() { result = normalizeMoveRejection(result) }() | |||
| 50 | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | 51 | if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID { | ||
| 51 | return errMovePaneInvalid | 52 | return errMovePaneInvalid | ||
| 52 | } | 53 | } | ||
| @@ -54,12 +55,15 @@ func (d *Daemon) moveTab(req moveTabRequest) error { | |||||
| 54 | source := moveSessionForLocatorLocked(d, req.Source) | 55 | source := moveSessionForLocatorLocked(d, req.Source) | ||
| 55 | destination := moveSessionForLocatorLocked(d, req.Destination) | 56 | destination := moveSessionForLocatorLocked(d, req.Destination) | ||
| 56 | d.mu.Unlock() | 57 | d.mu.Unlock() | ||
| 57 | - | if source == nil || destination == nil || source == destination { | 58 | + | if source == nil || destination == nil { |
| 59 | + | return errMoveStaleTarget | |||
| 60 | + | } | |||
| 61 | + | if source == destination { | |||
| 58 | return errMovePaneInvalid | 62 | return errMovePaneInvalid | ||
| 59 | } | 63 | } | ||
| 60 | reservation, err := d.reserveMoveLifecycles(source, destination) | 64 | reservation, err := d.reserveMoveLifecycles(source, destination) | ||
| 61 | if err != nil { | 65 | if err != nil { | ||
| 62 | - | return err | 66 | + | return errMoveStaleTarget |
| 63 | } | 67 | } | ||
| 64 | reservationHeld := true | 68 | reservationHeld := true | ||
| 65 | defer func() { | 69 | defer func() { | ||
| @@ -156,7 +160,7 @@ func (d *Daemon) moveTab(req moveTabRequest) error { | |||||
| 156 | if commit.err != nil { | 160 | if commit.err != nil { | ||
| 157 | return commit.err | 161 | return commit.err | ||
| 158 | } | 162 | } | ||
| 159 | - | return errMovePaneInvalid | 163 | + | return errMoveStaleTarget |
| 160 | } | 164 | } | ||
| 161 | fences.Release() | 165 | fences.Release() | ||
| 162 | if commit.oldTabCancel != nil { | 166 | if commit.oldTabCancel != nil { | ||
| @@ -232,16 +236,16 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||||
| 232 | d.mu.Lock() | 236 | d.mu.Lock() | ||
| 233 | defer d.mu.Unlock() | 237 | defer d.mu.Unlock() | ||
| 234 | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | 238 | if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination { | ||
| 235 | - | return nil, errMovePaneInvalid | 239 | + | return nil, errMoveStaleTarget |
| 236 | } | 240 | } | ||
| 237 | unlockSessions := lockAttachmentSessions(source, destination) | 241 | unlockSessions := lockAttachmentSessions(source, destination) | ||
| 238 | defer unlockSessions() | 242 | defer unlockSessions() | ||
| 239 | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | 243 | if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) { | ||
| 240 | - | return nil, errMovePaneInvalid | 244 | + | return nil, errMoveStaleTarget |
| 241 | } | 245 | } | ||
| 242 | moved := findMoveTabLocked(source, req.SourceTabID) | 246 | moved := findMoveTabLocked(source, req.SourceTabID) | ||
| 243 | if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) { | 247 | if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) { | ||
| 244 | - | return nil, errMovePaneInvalid | 248 | + | return nil, errMoveStaleTarget |
| 245 | } | 249 | } | ||
| 246 | destinationActive := destination.tabs[destination.active] | 250 | destinationActive := destination.tabs[destination.active] | ||
| 247 | destinationActive.mu.Lock() | 251 | destinationActive.mu.Lock() | ||
| @@ -250,10 +254,10 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||||
| 250 | moved.mu.Lock() | 254 | moved.mu.Lock() | ||
| 251 | defer moved.mu.Unlock() | 255 | defer moved.mu.Unlock() | ||
| 252 | if !moved.floatingTransferableLocked() { | 256 | if !moved.floatingTransferableLocked() { | ||
| 253 | - | return nil, errMovePaneInvalid | 257 | + | return nil, errMoveFloatingWarming |
| 254 | } | 258 | } | ||
| 255 | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | 259 | if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok { | ||
| 256 | - | return nil, errMovePaneInvalid | 260 | + | return nil, errMoveTooSmall |
| 257 | } | 261 | } | ||
| 258 | panes := make([]*pane, 0, len(moved.panes)+1) | 262 | panes := make([]*pane, 0, len(moved.panes)+1) | ||
| 259 | for _, p := range moved.panes { | 263 | for _, p := range moved.panes { | ||
M internal/usecase/daemon/move_tab_test.go +11 -8
| @@ -172,24 +172,25 @@ func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) { | |||
| 172 | 172 | ||
| 173 | 173 | func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | |
| 174 | 174 | tests := []struct { | |
| 175 | - | name string | |
| 176 | - | mutate func(source, destination *session, moved *tab, req *moveTabRequest) | |
| 175 | + | name string | |
| 176 | + | mutate func(source, destination *session, moved *tab, req *moveTabRequest) | |
| 177 | + | wantErr error | |
| 177 | 178 | }{ | |
| 178 | - | {name: "same session", mutate: func(source, _ *session, _ *tab, req *moveTabRequest) { | |
| 179 | + | {name: "same session", wantErr: errMovePaneInvalid, mutate: func(source, _ *session, _ *tab, req *moveTabRequest) { | |
| 179 | 180 | req.Destination = req.Source | |
| 180 | 181 | }}, | |
| 181 | - | {name: "stale incarnation", mutate: func(_, _ *session, _ *tab, req *moveTabRequest) { | |
| 182 | + | {name: "stale incarnation", wantErr: errMoveStaleTarget, mutate: func(_, _ *session, _ *tab, req *moveTabRequest) { | |
| 182 | 183 | req.Destination.Incarnation[0]++ | |
| 183 | 184 | }}, | |
| 184 | - | {name: "missing membership", mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) { | |
| 185 | + | {name: "missing membership", wantErr: errMoveStaleTarget, mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) { | |
| 185 | 186 | source.tabs = source.tabs[1:] | |
| 186 | 187 | _ = moved | |
| 187 | 188 | }}, | |
| 188 | - | {name: "warming floating", mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) { | |
| 189 | + | {name: "warming floating", wantErr: errMoveFloatingWarming, mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) { | |
| 189 | 190 | moved.floating.state = floatingWarming | |
| 190 | 191 | moved.floating.generation = 3 | |
| 191 | 192 | }}, | |
| 192 | - | {name: "destination teardown", mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) { | |
| 193 | + | {name: "destination teardown", wantErr: errMoveStaleTarget, mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) { | |
| 193 | 194 | destination.teardownMu.Lock() | |
| 194 | 195 | destination.teardownActive = true | |
| 195 | 196 | destination.teardownMu.Unlock() | |
| @@ -213,7 +214,9 @@ func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | |||
| 213 | 214 | destination.teardownMu.Lock() | |
| 214 | 215 | destination.teardownActive = false | |
| 215 | 216 | destination.teardownMu.Unlock() | |
| 216 | - | require.Error(t, err) | |
| 217 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 218 | + | var rejection *moveRejection | |
| 219 | + | require.ErrorAs(t, err, &rejection) | |
| 217 | 220 | require.Equal(t, beforeDestination, destination.tabs) | |
| 218 | 221 | require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot()) | |
| 219 | 222 | }) | |
| @@ -172,24 +172,25 @@ func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) { | |||||
| 172 | 172 | ||||
| 173 | func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | 173 | func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | ||
| 174 | tests := []struct { | 174 | tests := []struct { | ||
| 175 | - | name string | 175 | + | name string |
| 176 | - | mutate func(source, destination *session, moved *tab, req *moveTabRequest) | 176 | + | mutate func(source, destination *session, moved *tab, req *moveTabRequest) |
| 177 | + | wantErr error | |||
| 177 | }{ | 178 | }{ | ||
| 178 | - | {name: "same session", mutate: func(source, _ *session, _ *tab, req *moveTabRequest) { | 179 | + | {name: "same session", wantErr: errMovePaneInvalid, mutate: func(source, _ *session, _ *tab, req *moveTabRequest) { |
| 179 | req.Destination = req.Source | 180 | req.Destination = req.Source | ||
| 180 | }}, | 181 | }}, | ||
| 181 | - | {name: "stale incarnation", mutate: func(_, _ *session, _ *tab, req *moveTabRequest) { | 182 | + | {name: "stale incarnation", wantErr: errMoveStaleTarget, mutate: func(_, _ *session, _ *tab, req *moveTabRequest) { |
| 182 | req.Destination.Incarnation[0]++ | 183 | req.Destination.Incarnation[0]++ | ||
| 183 | }}, | 184 | }}, | ||
| 184 | - | {name: "missing membership", mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) { | 185 | + | {name: "missing membership", wantErr: errMoveStaleTarget, mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) { |
| 185 | source.tabs = source.tabs[1:] | 186 | source.tabs = source.tabs[1:] | ||
| 186 | _ = moved | 187 | _ = moved | ||
| 187 | }}, | 188 | }}, | ||
| 188 | - | {name: "warming floating", mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) { | 189 | + | {name: "warming floating", wantErr: errMoveFloatingWarming, mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) { |
| 189 | moved.floating.state = floatingWarming | 190 | moved.floating.state = floatingWarming | ||
| 190 | moved.floating.generation = 3 | 191 | moved.floating.generation = 3 | ||
| 191 | }}, | 192 | }}, | ||
| 192 | - | {name: "destination teardown", mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) { | 193 | + | {name: "destination teardown", wantErr: errMoveStaleTarget, mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) { |
| 193 | destination.teardownMu.Lock() | 194 | destination.teardownMu.Lock() | ||
| 194 | destination.teardownActive = true | 195 | destination.teardownActive = true | ||
| 195 | destination.teardownMu.Unlock() | 196 | destination.teardownMu.Unlock() | ||
| @@ -213,7 +214,9 @@ func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) { | |||||
| 213 | destination.teardownMu.Lock() | 214 | destination.teardownMu.Lock() | ||
| 214 | destination.teardownActive = false | 215 | destination.teardownActive = false | ||
| 215 | destination.teardownMu.Unlock() | 216 | destination.teardownMu.Unlock() | ||
| 216 | - | require.Error(t, err) | 217 | + | require.ErrorIs(t, err, tt.wantErr) |
| 218 | + | var rejection *moveRejection | |||
| 219 | + | require.ErrorAs(t, err, &rejection) | |||
| 217 | require.Equal(t, beforeDestination, destination.tabs) | 220 | require.Equal(t, beforeDestination, destination.tabs) | ||
| 218 | require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot()) | 221 | require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot()) | ||
| 219 | }) | 222 | }) | ||
M internal/usecase/daemon/move_test.go +1 -1
| @@ -289,7 +289,7 @@ func TestPrepareMovePaneCandidateIsPure(t *testing.T) { | |||
| 289 | 289 | destinationTree: layout.NewTree("pane-1"), | |
| 290 | 290 | destinationSize: domain.Size{Cols: 41, Rows: 2}, | |
| 291 | 291 | destinationNext: 2, | |
| 292 | - | wantErr: errMovePaneFloatingSibling, | |
| 292 | + | wantErr: errMoveFloatingWarming, | |
| 293 | 293 | }, | |
| 294 | 294 | { | |
| 295 | 295 | name: "final tiled pane rejects hidden floating sibling", | |
| @@ -289,7 +289,7 @@ func TestPrepareMovePaneCandidateIsPure(t *testing.T) { | |||||
| 289 | destinationTree: layout.NewTree("pane-1"), | 289 | destinationTree: layout.NewTree("pane-1"), | ||
| 290 | destinationSize: domain.Size{Cols: 41, Rows: 2}, | 290 | destinationSize: domain.Size{Cols: 41, Rows: 2}, | ||
| 291 | destinationNext: 2, | 291 | destinationNext: 2, | ||
| 292 | - | wantErr: errMovePaneFloatingSibling, | 292 | + | wantErr: errMoveFloatingWarming, |
| 293 | }, | 293 | }, | ||
| 294 | { | 294 | { | ||
| 295 | name: "final tiled pane rejects hidden floating sibling", | 295 | name: "final tiled pane rejects hidden floating sibling", | ||
M internal/usecase/daemon/palette.go +33 -1
| @@ -343,12 +343,19 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 343 | 343 | } | |
| 344 | 344 | if err != nil { | |
| 345 | 345 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | |
| 346 | - | d.reportError(sess, err) | |
| 346 | + | d.reportError(sess, paletteCommandNoticeError(cmd, err)) | |
| 347 | 347 | } else { | |
| 348 | 348 | d.recordPaletteUse(cmd.Code) | |
| 349 | 349 | } | |
| 350 | 350 | } | |
| 351 | 351 | ||
| 352 | + | func paletteCommandNoticeError(cmd command.Command, err error) error { | |
| 353 | + | if cmd.Scope == command.CommandScopeCrossSession { | |
| 354 | + | return movePickerUserError(err) | |
| 355 | + | } | |
| 356 | + | return err | |
| 357 | + | } | |
| 358 | + | ||
| 352 | 359 | func paletteArgs(query string, cmd command.Command) []string { | |
| 353 | 360 | action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query) | |
| 354 | 361 | if !ok { | |
| @@ -448,6 +455,31 @@ func (e paletteExec) ClosePane() error { | |||
| 448 | 455 | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | |
| 449 | 456 | } | |
| 450 | 457 | ||
| 458 | + | func (e paletteExec) OpenMovePanePicker() error { | |
| 459 | + | target := resolveDaemonActionTarget(e.sess) | |
| 460 | + | if target.tab == nil || target.pane == nil { | |
| 461 | + | return errMovePaneInvalid | |
| 462 | + | } | |
| 463 | + | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{ | |
| 464 | + | Session: sessionMoveLocator(e.sess), | |
| 465 | + | TabID: domain.TabStableID(target.tab.stableID), | |
| 466 | + | PaneID: domain.PaneStableID(target.pane.stableID), | |
| 467 | + | Client: e.ac, | |
| 468 | + | }) | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func (e paletteExec) OpenMoveTabPicker() error { | |
| 472 | + | target := resolveDaemonActionTarget(e.sess) | |
| 473 | + | if target.tab == nil { | |
| 474 | + | return errMovePaneInvalid | |
| 475 | + | } | |
| 476 | + | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{ | |
| 477 | + | Session: sessionMoveLocator(e.sess), | |
| 478 | + | TabID: domain.TabStableID(target.tab.stableID), | |
| 479 | + | Client: e.ac, | |
| 480 | + | }) | |
| 481 | + | } | |
| 482 | + | ||
| 451 | 483 | func (e paletteExec) focus(direction layout.Direction) error { | |
| 452 | 484 | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | |
| 453 | 485 | } | |
| @@ -343,12 +343,19 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 343 | } | 343 | } | ||
| 344 | if err != nil { | 344 | if err != nil { | ||
| 345 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | 345 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | ||
| 346 | - | d.reportError(sess, err) | 346 | + | d.reportError(sess, paletteCommandNoticeError(cmd, err)) |
| 347 | } else { | 347 | } else { | ||
| 348 | d.recordPaletteUse(cmd.Code) | 348 | d.recordPaletteUse(cmd.Code) | ||
| 349 | } | 349 | } | ||
| 350 | } | 350 | } | ||
| 351 | 351 | ||||
| 352 | + | func paletteCommandNoticeError(cmd command.Command, err error) error { | |||
| 353 | + | if cmd.Scope == command.CommandScopeCrossSession { | |||
| 354 | + | return movePickerUserError(err) | |||
| 355 | + | } | |||
| 356 | + | return err | |||
| 357 | + | } | |||
| 358 | + | ||||
| 352 | func paletteArgs(query string, cmd command.Command) []string { | 359 | func paletteArgs(query string, cmd command.Command) []string { | ||
| 353 | action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query) | 360 | action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query) | ||
| 354 | if !ok { | 361 | if !ok { | ||
| @@ -448,6 +455,31 @@ func (e paletteExec) ClosePane() error { | |||||
| 448 | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | 455 | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | ||
| 449 | } | 456 | } | ||
| 450 | 457 | ||||
| 458 | + | func (e paletteExec) OpenMovePanePicker() error { | |||
| 459 | + | target := resolveDaemonActionTarget(e.sess) | |||
| 460 | + | if target.tab == nil || target.pane == nil { | |||
| 461 | + | return errMovePaneInvalid | |||
| 462 | + | } | |||
| 463 | + | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{ | |||
| 464 | + | Session: sessionMoveLocator(e.sess), | |||
| 465 | + | TabID: domain.TabStableID(target.tab.stableID), | |||
| 466 | + | PaneID: domain.PaneStableID(target.pane.stableID), | |||
| 467 | + | Client: e.ac, | |||
| 468 | + | }) | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func (e paletteExec) OpenMoveTabPicker() error { | |||
| 472 | + | target := resolveDaemonActionTarget(e.sess) | |||
| 473 | + | if target.tab == nil { | |||
| 474 | + | return errMovePaneInvalid | |||
| 475 | + | } | |||
| 476 | + | return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{ | |||
| 477 | + | Session: sessionMoveLocator(e.sess), | |||
| 478 | + | TabID: domain.TabStableID(target.tab.stableID), | |||
| 479 | + | Client: e.ac, | |||
| 480 | + | }) | |||
| 481 | + | } | |||
| 482 | + | ||||
| 451 | func (e paletteExec) focus(direction layout.Direction) error { | 483 | func (e paletteExec) focus(direction layout.Direction) error { | ||
| 452 | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | 484 | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | ||
| 453 | } | 485 | } | ||
M internal/usecase/daemon/palette_test.go +54 -2
| @@ -143,6 +143,58 @@ func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) { | |||
| 143 | 143 | require.Equal(t, domain.NoticeTabSpawn, history[0].Code) | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | + | func TestPaletteCommandNoticeErrorPreservesTypedErrorsAndMapsMoveFailures(t *testing.T) { | |
| 147 | + | typedTabSpawn := domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab", errors.New("open failed")) | |
| 148 | + | tests := []struct { | |
| 149 | + | name, slug, message string | |
| 150 | + | err error | |
| 151 | + | code domain.NoticeCode | |
| 152 | + | severity domain.NoticeSeverity | |
| 153 | + | wantSame bool | |
| 154 | + | }{ | |
| 155 | + | {name: "CNT typed command error", slug: "new-tab", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true}, | |
| 156 | + | {name: "MPN no destination", slug: "move-pane", err: errNoMoveDestination, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "No destination available."}, | |
| 157 | + | {name: "MTB warming floating", slug: "move-tab", err: errMoveFloatingWarming, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Wait for the floating pane to finish opening."}, | |
| 158 | + | {name: "MPN final pane with floating slot", slug: "move-pane", err: errMoveFinalSourceFloating, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Close the floating pane or move the whole tab."}, | |
| 159 | + | {name: "MPN destination too small", slug: "move-pane", err: errMoveTooSmall, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Not enough space in destination tab."}, | |
| 160 | + | {name: "MTB stale destination", slug: "move-tab", err: errMoveStaleTarget, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Destination is no longer available."}, | |
| 161 | + | {name: "MPN generic invalid", slug: "move-pane", err: errMovePaneInvalid, code: domain.NoticeInternal, severity: domain.NoticeError, message: "Move failed."}, | |
| 162 | + | {name: "MPN typed application error", slug: "move-pane", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true}, | |
| 163 | + | } | |
| 164 | + | for _, tt := range tests { | |
| 165 | + | t.Run(tt.name, func(t *testing.T) { | |
| 166 | + | cmd, ok := command.BySlug(tt.slug) | |
| 167 | + | require.True(t, ok) | |
| 168 | + | got := paletteCommandNoticeError(cmd, tt.err) | |
| 169 | + | if tt.wantSame { | |
| 170 | + | require.Same(t, tt.err, got) | |
| 171 | + | } | |
| 172 | + | var userErr *domain.UserError | |
| 173 | + | require.ErrorAs(t, got, &userErr) | |
| 174 | + | require.Equal(t, tt.code, userErr.Code) | |
| 175 | + | require.Equal(t, tt.severity, userErr.Severity) | |
| 176 | + | require.Equal(t, tt.message, userErr.Msg) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestPaletteCommandNoticeErrorUsesCommandScope(t *testing.T) { | |
| 182 | + | t.Run("future cross-session command inherits move rejection presentation", func(t *testing.T) { | |
| 183 | + | cmd := command.Command{Slug: "future-transfer", Scope: command.CommandScopeCrossSession} | |
| 184 | + | got := paletteCommandNoticeError(cmd, errMoveStaleTarget) | |
| 185 | + | var userErr *domain.UserError | |
| 186 | + | require.ErrorAs(t, got, &userErr) | |
| 187 | + | require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code) | |
| 188 | + | require.Equal(t, "Destination is no longer available.", userErr.Msg) | |
| 189 | + | }) | |
| 190 | + | ||
| 191 | + | t.Run("move-like slug without metadata is unchanged", func(t *testing.T) { | |
| 192 | + | err := errors.New("ordinary failure") | |
| 193 | + | got := paletteCommandNoticeError(command.Command{Slug: "move-pane"}, err) | |
| 194 | + | require.Same(t, err, got) | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | ||
| 146 | 198 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |
| 147 | 199 | p, release := newBlockingPTY(t) | |
| 148 | 200 | defer release() | |
| @@ -630,12 +682,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 630 | 682 | for i, cmd := range commands { | |
| 631 | 683 | codes[i] = cmd.Code | |
| 632 | 684 | } | |
| 633 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | |
| 685 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | |
| 634 | 686 | } | |
| 635 | 687 | ||
| 636 | 688 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 637 | 689 | d := &Daemon{} | |
| 638 | - | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} | |
| 690 | + | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} | |
| 639 | 691 | ||
| 640 | 692 | var wg sync.WaitGroup | |
| 641 | 693 | for range 50 { | |
| @@ -143,6 +143,58 @@ func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) { | |||||
| 143 | require.Equal(t, domain.NoticeTabSpawn, history[0].Code) | 143 | require.Equal(t, domain.NoticeTabSpawn, history[0].Code) | ||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | + | func TestPaletteCommandNoticeErrorPreservesTypedErrorsAndMapsMoveFailures(t *testing.T) { | |||
| 147 | + | typedTabSpawn := domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab", errors.New("open failed")) | |||
| 148 | + | tests := []struct { | |||
| 149 | + | name, slug, message string | |||
| 150 | + | err error | |||
| 151 | + | code domain.NoticeCode | |||
| 152 | + | severity domain.NoticeSeverity | |||
| 153 | + | wantSame bool | |||
| 154 | + | }{ | |||
| 155 | + | {name: "CNT typed command error", slug: "new-tab", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true}, | |||
| 156 | + | {name: "MPN no destination", slug: "move-pane", err: errNoMoveDestination, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "No destination available."}, | |||
| 157 | + | {name: "MTB warming floating", slug: "move-tab", err: errMoveFloatingWarming, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Wait for the floating pane to finish opening."}, | |||
| 158 | + | {name: "MPN final pane with floating slot", slug: "move-pane", err: errMoveFinalSourceFloating, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Close the floating pane or move the whole tab."}, | |||
| 159 | + | {name: "MPN destination too small", slug: "move-pane", err: errMoveTooSmall, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Not enough space in destination tab."}, | |||
| 160 | + | {name: "MTB stale destination", slug: "move-tab", err: errMoveStaleTarget, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Destination is no longer available."}, | |||
| 161 | + | {name: "MPN generic invalid", slug: "move-pane", err: errMovePaneInvalid, code: domain.NoticeInternal, severity: domain.NoticeError, message: "Move failed."}, | |||
| 162 | + | {name: "MPN typed application error", slug: "move-pane", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true}, | |||
| 163 | + | } | |||
| 164 | + | for _, tt := range tests { | |||
| 165 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 166 | + | cmd, ok := command.BySlug(tt.slug) | |||
| 167 | + | require.True(t, ok) | |||
| 168 | + | got := paletteCommandNoticeError(cmd, tt.err) | |||
| 169 | + | if tt.wantSame { | |||
| 170 | + | require.Same(t, tt.err, got) | |||
| 171 | + | } | |||
| 172 | + | var userErr *domain.UserError | |||
| 173 | + | require.ErrorAs(t, got, &userErr) | |||
| 174 | + | require.Equal(t, tt.code, userErr.Code) | |||
| 175 | + | require.Equal(t, tt.severity, userErr.Severity) | |||
| 176 | + | require.Equal(t, tt.message, userErr.Msg) | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestPaletteCommandNoticeErrorUsesCommandScope(t *testing.T) { | |||
| 182 | + | t.Run("future cross-session command inherits move rejection presentation", func(t *testing.T) { | |||
| 183 | + | cmd := command.Command{Slug: "future-transfer", Scope: command.CommandScopeCrossSession} | |||
| 184 | + | got := paletteCommandNoticeError(cmd, errMoveStaleTarget) | |||
| 185 | + | var userErr *domain.UserError | |||
| 186 | + | require.ErrorAs(t, got, &userErr) | |||
| 187 | + | require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code) | |||
| 188 | + | require.Equal(t, "Destination is no longer available.", userErr.Msg) | |||
| 189 | + | }) | |||
| 190 | + | ||||
| 191 | + | t.Run("move-like slug without metadata is unchanged", func(t *testing.T) { | |||
| 192 | + | err := errors.New("ordinary failure") | |||
| 193 | + | got := paletteCommandNoticeError(command.Command{Slug: "move-pane"}, err) | |||
| 194 | + | require.Same(t, err, got) | |||
| 195 | + | }) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 146 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | 198 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | ||
| 147 | p, release := newBlockingPTY(t) | 199 | p, release := newBlockingPTY(t) | ||
| 148 | defer release() | 200 | defer release() | ||
| @@ -630,12 +682,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||||
| 630 | for i, cmd := range commands { | 682 | for i, cmd := range commands { | ||
| 631 | codes[i] = cmd.Code | 683 | codes[i] = cmd.Code | ||
| 632 | } | 684 | } | ||
| 633 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | 685 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) |
| 634 | } | 686 | } | ||
| 635 | 687 | ||||
| 636 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | 688 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | ||
| 637 | d := &Daemon{} | 689 | d := &Daemon{} | ||
| 638 | - | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} | 690 | + | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} |
| 639 | 691 | ||||
| 640 | var wg sync.WaitGroup | 692 | var wg sync.WaitGroup | ||
| 641 | for range 50 { | 693 | for range 50 { | ||
M internal/usecase/daemon/picker.go +77 -7
| @@ -14,10 +14,35 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | var ( | |
| 18 | - | pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |
| 19 | - | errNoMoveDestination = errors.New("no eligible move destination") | |
| 20 | - | ) | |
| 17 | + | func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { | |
| 18 | + | if d == nil || source.Session.ID == "" || source.TabID == "" { | |
| 19 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| 20 | + | } | |
| 21 | + | sess := d.sessionByID(source.Session.ID) | |
| 22 | + | if sess == nil || sess.incarnation != source.Session.Incarnation { | |
| 23 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| 24 | + | } | |
| 25 | + | sess.mu.Lock() | |
| 26 | + | defer sess.mu.Unlock() | |
| 27 | + | if source.Client != nil && sess.client != source.Client { | |
| 28 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid) | |
| 29 | + | } | |
| 30 | + | tb := findMoveTabLocked(sess, source.TabID) | |
| 31 | + | if tb == nil { | |
| 32 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid) | |
| 33 | + | } | |
| 34 | + | if source.PaneID == "" { | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | tb.mu.Lock() | |
| 38 | + | defer tb.mu.Unlock() | |
| 39 | + | if paneByStableIDLocked(tb, string(source.PaneID)) == nil { | |
| 40 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid) | |
| 41 | + | } | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |
| 21 | 46 | ||
| 22 | 47 | type pickerIntent uint8 | |
| 23 | 48 | ||
| @@ -37,6 +62,7 @@ type moveSourceLocator struct { | |||
| 37 | 62 | Session moveSessionLocator | |
| 38 | 63 | TabID domain.TabStableID | |
| 39 | 64 | PaneID domain.PaneStableID | |
| 65 | + | Client *attachedClient | |
| 40 | 66 | } | |
| 41 | 67 | ||
| 42 | 68 | // enterPicker preserves the existing navigation entry point. Navigation always | |
| @@ -228,9 +254,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 228 | 254 | }) | |
| 229 | 255 | var target picker.Target | |
| 230 | 256 | var ok bool | |
| 257 | + | var intent pickerIntent | |
| 258 | + | var source moveSourceLocator | |
| 231 | 259 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | |
| 232 | 260 | target, ok = rt.picker.Selected() | |
| 233 | 261 | } | |
| 262 | + | intent, source = rt.pickerIntent, rt.pickerSource | |
| 234 | 263 | rt.pickerMu.Unlock() | |
| 235 | 264 | ||
| 236 | 265 | if result.action == 'x' { | |
| @@ -258,11 +287,27 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 258 | 287 | if result.changed { | |
| 259 | 288 | d.registerPreviewForSelection(ac) | |
| 260 | 289 | } | |
| 261 | - | navigating := (result.action == '\r' || result.action == '\n') && ok | |
| 262 | - | if result.exit && !navigating { | |
| 290 | + | committing := (result.action == '\r' || result.action == '\n') && ok | |
| 291 | + | if result.exit && !committing { | |
| 263 | 292 | d.closePicker(ac) | |
| 264 | 293 | } | |
| 265 | - | if navigating { | |
| 294 | + | if committing { | |
| 295 | + | if intent == pickerMovePane || intent == pickerMoveTab { | |
| 296 | + | if err := d.movePickerSourceError(source); err != nil { | |
| 297 | + | d.reportError(sess, err) | |
| 298 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 299 | + | return | |
| 300 | + | } | |
| 301 | + | d.closePicker(ac) | |
| 302 | + | err := d.commitMovePickerSelection(intent, source, target) | |
| 303 | + | if err != nil { | |
| 304 | + | d.reportError(sess, movePickerUserError(err)) | |
| 305 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 306 | + | return | |
| 307 | + | } | |
| 308 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 309 | + | return | |
| 310 | + | } | |
| 266 | 311 | var err error | |
| 267 | 312 | if len(effects) != 0 && effects[0] != nil { | |
| 268 | 313 | err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| @@ -421,6 +466,31 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 421 | 466 | } | |
| 422 | 467 | } | |
| 423 | 468 | ||
| 469 | + | func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error { | |
| 470 | + | destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name} | |
| 471 | + | switch intent { | |
| 472 | + | case pickerMovePane: | |
| 473 | + | if target.TabID == "" { | |
| 474 | + | return errMovePaneInvalid | |
| 475 | + | } | |
| 476 | + | return d.movePane(movePaneRequest{ | |
| 477 | + | Source: source.Session, | |
| 478 | + | SourceTabID: source.TabID, | |
| 479 | + | SourcePaneID: source.PaneID, | |
| 480 | + | Destination: destination, | |
| 481 | + | DestinationTabID: target.TabID, | |
| 482 | + | }) | |
| 483 | + | case pickerMoveTab: | |
| 484 | + | return d.moveTab(moveTabRequest{ | |
| 485 | + | Source: source.Session, | |
| 486 | + | SourceTabID: source.TabID, | |
| 487 | + | Destination: destination, | |
| 488 | + | }) | |
| 489 | + | default: | |
| 490 | + | return errMovePaneInvalid | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | ||
| 424 | 494 | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 425 | 495 | ac.overlays.pickerMu.Lock() | |
| 426 | 496 | ac.overlays.picker = nil | |
| @@ -14,10 +14,35 @@ import ( | |||||
| 14 | "github.com/bnema/vev/pkg/renderer" | 14 | "github.com/bnema/vev/pkg/renderer" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | var ( | 17 | + | func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { |
| 18 | - | pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | 18 | + | if d == nil || source.Session.ID == "" || source.TabID == "" { |
| 19 | - | errNoMoveDestination = errors.New("no eligible move destination") | 19 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) |
| 20 | - | ) | 20 | + | } |
| 21 | + | sess := d.sessionByID(source.Session.ID) | |||
| 22 | + | if sess == nil || sess.incarnation != source.Session.Incarnation { | |||
| 23 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |||
| 24 | + | } | |||
| 25 | + | sess.mu.Lock() | |||
| 26 | + | defer sess.mu.Unlock() | |||
| 27 | + | if source.Client != nil && sess.client != source.Client { | |||
| 28 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid) | |||
| 29 | + | } | |||
| 30 | + | tb := findMoveTabLocked(sess, source.TabID) | |||
| 31 | + | if tb == nil { | |||
| 32 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid) | |||
| 33 | + | } | |||
| 34 | + | if source.PaneID == "" { | |||
| 35 | + | return nil | |||
| 36 | + | } | |||
| 37 | + | tb.mu.Lock() | |||
| 38 | + | defer tb.mu.Unlock() | |||
| 39 | + | if paneByStableIDLocked(tb, string(source.PaneID)) == nil { | |||
| 40 | + | return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid) | |||
| 41 | + | } | |||
| 42 | + | return nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | |||
| 21 | 46 | ||||
| 22 | type pickerIntent uint8 | 47 | type pickerIntent uint8 | ||
| 23 | 48 | ||||
| @@ -37,6 +62,7 @@ type moveSourceLocator struct { | |||||
| 37 | Session moveSessionLocator | 62 | Session moveSessionLocator | ||
| 38 | TabID domain.TabStableID | 63 | TabID domain.TabStableID | ||
| 39 | PaneID domain.PaneStableID | 64 | PaneID domain.PaneStableID | ||
| 65 | + | Client *attachedClient | |||
| 40 | } | 66 | } | ||
| 41 | 67 | ||||
| 42 | // enterPicker preserves the existing navigation entry point. Navigation always | 68 | // enterPicker preserves the existing navigation entry point. Navigation always | ||
| @@ -228,9 +254,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 228 | }) | 254 | }) | ||
| 229 | var target picker.Target | 255 | var target picker.Target | ||
| 230 | var ok bool | 256 | var ok bool | ||
| 257 | + | var intent pickerIntent | |||
| 258 | + | var source moveSourceLocator | |||
| 231 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | 259 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | ||
| 232 | target, ok = rt.picker.Selected() | 260 | target, ok = rt.picker.Selected() | ||
| 233 | } | 261 | } | ||
| 262 | + | intent, source = rt.pickerIntent, rt.pickerSource | |||
| 234 | rt.pickerMu.Unlock() | 263 | rt.pickerMu.Unlock() | ||
| 235 | 264 | ||||
| 236 | if result.action == 'x' { | 265 | if result.action == 'x' { | ||
| @@ -258,11 +287,27 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 258 | if result.changed { | 287 | if result.changed { | ||
| 259 | d.registerPreviewForSelection(ac) | 288 | d.registerPreviewForSelection(ac) | ||
| 260 | } | 289 | } | ||
| 261 | - | navigating := (result.action == '\r' || result.action == '\n') && ok | 290 | + | committing := (result.action == '\r' || result.action == '\n') && ok |
| 262 | - | if result.exit && !navigating { | 291 | + | if result.exit && !committing { |
| 263 | d.closePicker(ac) | 292 | d.closePicker(ac) | ||
| 264 | } | 293 | } | ||
| 265 | - | if navigating { | 294 | + | if committing { |
| 295 | + | if intent == pickerMovePane || intent == pickerMoveTab { | |||
| 296 | + | if err := d.movePickerSourceError(source); err != nil { | |||
| 297 | + | d.reportError(sess, err) | |||
| 298 | + | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 299 | + | return | |||
| 300 | + | } | |||
| 301 | + | d.closePicker(ac) | |||
| 302 | + | err := d.commitMovePickerSelection(intent, source, target) | |||
| 303 | + | if err != nil { | |||
| 304 | + | d.reportError(sess, movePickerUserError(err)) | |||
| 305 | + | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 306 | + | return | |||
| 307 | + | } | |||
| 308 | + | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 309 | + | return | |||
| 310 | + | } | |||
| 266 | var err error | 311 | var err error | ||
| 267 | if len(effects) != 0 && effects[0] != nil { | 312 | if len(effects) != 0 && effects[0] != nil { | ||
| 268 | err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | 313 | err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | ||
| @@ -421,6 +466,31 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 421 | } | 466 | } | ||
| 422 | } | 467 | } | ||
| 423 | 468 | ||||
| 469 | + | func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error { | |||
| 470 | + | destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name} | |||
| 471 | + | switch intent { | |||
| 472 | + | case pickerMovePane: | |||
| 473 | + | if target.TabID == "" { | |||
| 474 | + | return errMovePaneInvalid | |||
| 475 | + | } | |||
| 476 | + | return d.movePane(movePaneRequest{ | |||
| 477 | + | Source: source.Session, | |||
| 478 | + | SourceTabID: source.TabID, | |||
| 479 | + | SourcePaneID: source.PaneID, | |||
| 480 | + | Destination: destination, | |||
| 481 | + | DestinationTabID: target.TabID, | |||
| 482 | + | }) | |||
| 483 | + | case pickerMoveTab: | |||
| 484 | + | return d.moveTab(moveTabRequest{ | |||
| 485 | + | Source: source.Session, | |||
| 486 | + | SourceTabID: source.TabID, | |||
| 487 | + | Destination: destination, | |||
| 488 | + | }) | |||
| 489 | + | default: | |||
| 490 | + | return errMovePaneInvalid | |||
| 491 | + | } | |||
| 492 | + | } | |||
| 493 | + | ||||
| 424 | func (d *Daemon) closePicker(ac *attachedClient) { | 494 | func (d *Daemon) closePicker(ac *attachedClient) { | ||
| 425 | ac.overlays.pickerMu.Lock() | 495 | ac.overlays.pickerMu.Lock() | ||
| 426 | ac.overlays.picker = nil | 496 | ac.overlays.picker = nil | ||