detached 35e7583

feat(command): expose pane and tab moves

commit
35e7583be560d909137ffa2a9d05d43d7ac617f3
author
brice · · 2026-07-28 11:18 UTC
parent
ac63f74
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
105105 }
106106 }
107107
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+
108175 func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) {
109176 for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
110177 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 {
4848 ToggleStack() error
4949 ToggleFloatingPane() error
5050 ClosePane() error
51+ OpenMovePanePicker() error
52+ OpenMoveTabPicker() error
5153 FocusPaneLeft() error
5254 FocusPaneRight() error
5355 FocusPaneUp() error
@@ -97,6 +99,16 @@ const (
9799 TargetPane
98100 )
99101
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+
100112 // ControlOptions carries per-invocation output preferences.
101113 type ControlOptions struct {
102114 JSON bool
@@ -113,6 +125,8 @@ type ControlContext interface {
113125 CreateSessionNamed(name string) error
114126 CloseTab() error
115127 ClosePane() error
128+ MovePane(destinationSession, destinationTabID string) error
129+ MoveTab(destinationSession string) error
116130 SplitRight() error
117131 SplitLeft() error
118132 SplitUp() error
@@ -148,6 +162,7 @@ type Command struct {
148162 Scriptable bool
149163 PaletteVisible bool
150164 Target TargetKind
165+ Scope CommandScope
151166 Run func(Context, []string) error
152167 Control func(ControlContext, []string, ControlOptions) (ControlResult, error)
153168 }
@@ -48,6 +48,8 @@ type Context interface {
48 ToggleStack() error48 ToggleStack() error
49 ToggleFloatingPane() error49 ToggleFloatingPane() error
50 ClosePane() error50 ClosePane() error
51+ OpenMovePanePicker() error
52+ OpenMoveTabPicker() error
51 FocusPaneLeft() error53 FocusPaneLeft() error
52 FocusPaneRight() error54 FocusPaneRight() error
53 FocusPaneUp() error55 FocusPaneUp() error
@@ -97,6 +99,16 @@ const (
97 TargetPane99 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 bool114 JSON bool
@@ -113,6 +125,8 @@ type ControlContext interface {
113 CreateSessionNamed(name string) error125 CreateSessionNamed(name string) error
114 CloseTab() error126 CloseTab() error
115 ClosePane() error127 ClosePane() error
128+ MovePane(destinationSession, destinationTabID string) error
129+ MoveTab(destinationSession string) error
116 SplitRight() error130 SplitRight() error
117 SplitLeft() error131 SplitLeft() error
118 SplitUp() error132 SplitUp() error
@@ -148,6 +162,7 @@ type Command struct {
148 Scriptable bool162 Scriptable bool
149 PaletteVisible bool163 PaletteVisible bool
150 Target TargetKind164 Target TargetKind
165+ Scope CommandScope
151 Run func(Context, []string) error166 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_
790790 return _c
791791 }
792792
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+
793881 // OpenNotifications provides a mock function for the type MockContext
794882 func (_mock *MockContext) OpenNotifications() error {
795883 ret := _mock.Called()
@@ -790,6 +790,94 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_
790 return _c790 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 MockContext881 // 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 {
1616 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() }),
1717 paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),
1818 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(),
1921 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() }),
2022 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() }),
2123 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
7678 return cmd
7779 }
7880
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+
79111 func toastCommand() Command {
80112 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) {
81113 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 cmd78 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 (
99
1010 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
1111 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"}
1313
1414 if len(commands) != len(wantCodes) {
1515 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -39,6 +39,76 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
3939 }
4040 }
4141
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+
42112 func TestBySlugExactMatch(t *testing.T) {
43113 tests := []struct {
44114 slug string
@@ -70,6 +140,7 @@ func TestRegistryControlMetadata(t *testing.T) {
70140 "new-session": TargetSession, "toast": TargetSession,
71141 "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane,
72142 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
143+ "move-pane": TargetPane, "move-tab": TargetTab,
73144 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
74145 }
75146 notScriptable := []string{
@@ -188,25 +259,29 @@ func (s *controlSpy) CreateTab() error { return s.record("new-t
188259 func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) }
189260 func (s *controlSpy) CloseTab() error { return s.record("close-tab") }
190261 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) }
210285 func (s *controlSpy) Toast(level, message string) error {
211286 return s.record("toast:" + level + ":" + message)
212287 }
@@ -289,6 +364,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
289364 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
290365 {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},
291366 {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() }},
292369 {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
293370 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
294371 {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 string114 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
5252 if sess == nil {
5353 return commandFailure(code, text)
5454 }
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+
5563 sess.dispatchMu.Lock()
5664 defer sess.dispatchMu.Unlock()
5765
@@ -72,6 +80,8 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports
7280 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
7381 case errors.Is(err, errSessionNameInUse):
7482 return commandFailure(ports.ErrNameTaken, err.Error())
83+ case isMoveCommandError(err):
84+ return moveCommandFailure(err)
7585 case errors.Is(err, layout.ErrNotInSplit):
7686 return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split")
7787 case errors.Is(err, layout.ErrTooSmall):
@@ -86,8 +96,8 @@ func commandFailure(code uint16, text string) ports.CommandResult {
8696 }
8797
8898 // 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.
91101 func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {
92102 d.mu.Lock()
93103 defer d.mu.Unlock()
@@ -106,13 +116,18 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
106116 if request.TargetTab == "" || request.TargetPane == "" {
107117 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
108118 }
119+ var match *session
109120 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"
115126 }
127+ match = sess
128+ }
129+ if match != nil {
130+ return match, 0, ""
116131 }
117132 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
118133 }
@@ -393,6 +408,48 @@ func (e controlExec) CloseTab() error {
393408 func (e controlExec) ClosePane() error {
394409 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
395410 }
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+}
396453 func (e controlExec) split(direction layout.Direction) error {
397454 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
398455 }
@@ -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-session98 // resolveTargetSession applies explicit-name, stable-ID, then unique-session
89-// resolution. A name paired with IDs is advisory only when no live session has99+// 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) {
9898 {name: "missing stable IDs", arrange: func(d *Daemon) {
9999 addControlSession(d, "work", "t_work", "p_work")
100100 }, 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},
105105 {name: "self requires both IDs", arrange: func(d *Daemon) {
106106 addControlSession(d, "work", "t_work", "p_work")
107107 }, 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) {
636636 require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
637637 }
638638
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+
639993 func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
640994 t.Helper()
641995 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 {
2525 // fallible work happens before the membership publication section. Once the
2626 // section starts, the caller holds the ordered architecture locks and only
2727 // 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) }()
2930 if d == nil || req.Source.ID == "" || req.Destination.ID == "" ||
3031 req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
3132 return errMovePaneInvalid
@@ -36,12 +37,12 @@ func (d *Daemon) movePane(req movePaneRequest) error {
3637 destination := moveSessionForLocatorLocked(d, req.Destination)
3738 d.mu.Unlock()
3839 if source == nil || destination == nil {
39- return errMovePaneInvalid
40+ return errMoveStaleTarget
4041 }
4142
4243 reservation, err := d.reserveMoveLifecycles(source, destination)
4344 if err != nil {
44- return err
45+ return errMoveStaleTarget
4546 }
4647 reservationHeld := true
4748 defer func() {
@@ -188,7 +189,7 @@ func (d *Daemon) movePane(req movePaneRequest) error {
188189 if commit.err != nil {
189190 return commit.err
190191 }
191- return errMovePaneInvalid
192+ return errMoveStaleTarget
192193 }
193194 // Owner/layout fences end at the atomic publication boundary. The immutable
194195 // 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 the25 // fallible work happens before the membership publication section. Once the
26 // section starts, the caller holds the ordered architecture locks and only26 // 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 errMovePaneInvalid32 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 errMovePaneInvalid40+ 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 err45+ return errMoveStaleTarget
45 }46 }
46 reservationHeld := true47 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.err190 return commit.err
190 }191 }
191- return errMovePaneInvalid192+ return errMoveStaleTarget
192 }193 }
193 // Owner/layout fences end at the atomic publication boundary. The immutable194 // 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
2626 d.mu.Lock()
2727 defer d.mu.Unlock()
2828 if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
29- return nil, errMovePaneInvalid
29+ return nil, errMoveStaleTarget
3030 }
3131 unlockSessions := lockAttachmentSessions(source, destination)
3232 defer unlockSessions()
3333 if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
34- return nil, errMovePaneInvalid
34+ return nil, errMoveStaleTarget
3535 }
3636
3737 sourceTab := findMoveTabLocked(source, req.SourceTabID)
3838 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 {
4043 return nil, errMovePaneInvalid
4144 }
4245 unlockTabs := lockMoveTabs(sourceTab, destinationTab)
@@ -50,7 +53,7 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
5053 }
5154 }
5255 if movedPane == nil {
53- return nil, errMovePaneInvalid
56+ return nil, errMoveStaleTarget
5457 }
5558
5659 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, errMovePaneInvalid29+ 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, errMovePaneInvalid34+ 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, errMovePaneInvalid43 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, errMovePaneInvalid56+ 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 (
99 "github.com/bnema/vev/internal/usecase/layout"
1010 )
1111
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-
1812 // movePaneCandidate contains only unpublished topology and identity changes.
1913 // The caller must revalidate the live tabs before committing these values.
2014 type movePaneCandidate struct {
@@ -41,7 +35,7 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC
4135 source.tree == nil || source.tree.Root == nil || source.panes[moved.id] != moved ||
4236 !layout.ContainsLeaf(source.tree.Root, moved.id) || destination.tree == nil || destination.tree.Root == nil ||
4337 destination.panes[destination.tree.Focus] == nil || !layout.ContainsLeaf(destination.tree.Root, destination.tree.Focus) {
44- return nil, layout.ErrNotFound
38+ return nil, errMovePaneInvalid
4539 }
4640
4741 candidate := &movePaneCandidate{
@@ -53,19 +47,22 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC
5347
5448 sourceLeaves := layout.LeafIDs(source.tree.Root)
5549 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
5855 }
5956 candidate.removeSourceTab = true
6057 } else {
6158 candidate.sourceTree = source.tree.Clone()
6259 if err := candidate.sourceTree.Close(moved.id); err != nil {
63- return nil, err
60+ return nil, errMovePaneInvalid
6461 }
6562 var ok bool
6663 candidate.sourcePlacements, ok = layout.Solve(candidate.sourceTree.Root, tabArea(source))
6764 if !ok || candidate.sourceTree.Focus == "" || !layout.ContainsLeaf(candidate.sourceTree.Root, candidate.sourceTree.Focus) {
68- return nil, layout.ErrTooSmall
65+ return nil, errMoveTooSmall
6966 }
7067 }
7168
@@ -77,12 +74,15 @@ func prepareMovePaneCandidate(source, destination *tab, moved *pane) (*movePaneC
7774 candidate.destinationTree = destination.tree.Clone()
7875 area := tabArea(destination)
7976 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
8181 }
8282 var ok bool
8383 candidate.destinationPlacements, ok = layout.Solve(candidate.destinationTree.Root, area)
8484 if !ok {
85- return nil, layout.ErrTooSmall
85+ return nil, errMoveTooSmall
8686 }
8787 return candidate, nil
8888 }
@@ -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.ErrNotFound38+ 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, errMovePaneFloatingSibling51+ case floatingWarming:
52+ return nil, errMoveFloatingWarming
53+ case floatingHidden, floatingVisible:
54+ return nil, errMoveFinalSourceFloating
58 }55 }
59 candidate.removeSourceTab = true56 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, err60+ return nil, errMovePaneInvalid
64 }61 }
65 var ok bool62 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.ErrTooSmall65+ 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, err77+ if errors.Is(err, layout.ErrTooSmall) {
78+ return nil, errMoveTooSmall
79+ }
80+ return nil, errMovePaneInvalid
81 }81 }
82 var ok bool82 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.ErrTooSmall85+ return nil, errMoveTooSmall
86 }86 }
87 return candidate, nil87 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 {
4646 err error
4747 }
4848
49-func (d *Daemon) moveTab(req moveTabRequest) error {
49+func (d *Daemon) moveTab(req moveTabRequest) (result error) {
50+ defer func() { result = normalizeMoveRejection(result) }()
5051 if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
5152 return errMovePaneInvalid
5253 }
@@ -54,12 +55,15 @@ func (d *Daemon) moveTab(req moveTabRequest) error {
5455 source := moveSessionForLocatorLocked(d, req.Source)
5556 destination := moveSessionForLocatorLocked(d, req.Destination)
5657 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 {
5862 return errMovePaneInvalid
5963 }
6064 reservation, err := d.reserveMoveLifecycles(source, destination)
6165 if err != nil {
62- return err
66+ return errMoveStaleTarget
6367 }
6468 reservationHeld := true
6569 defer func() {
@@ -156,7 +160,7 @@ func (d *Daemon) moveTab(req moveTabRequest) error {
156160 if commit.err != nil {
157161 return commit.err
158162 }
159- return errMovePaneInvalid
163+ return errMoveStaleTarget
160164 }
161165 fences.Release()
162166 if commit.oldTabCancel != nil {
@@ -232,16 +236,16 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
232236 d.mu.Lock()
233237 defer d.mu.Unlock()
234238 if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
235- return nil, errMovePaneInvalid
239+ return nil, errMoveStaleTarget
236240 }
237241 unlockSessions := lockAttachmentSessions(source, destination)
238242 defer unlockSessions()
239243 if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
240- return nil, errMovePaneInvalid
244+ return nil, errMoveStaleTarget
241245 }
242246 moved := findMoveTabLocked(source, req.SourceTabID)
243247 if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) {
244- return nil, errMovePaneInvalid
248+ return nil, errMoveStaleTarget
245249 }
246250 destinationActive := destination.tabs[destination.active]
247251 destinationActive.mu.Lock()
@@ -250,10 +254,10 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
250254 moved.mu.Lock()
251255 defer moved.mu.Unlock()
252256 if !moved.floatingTransferableLocked() {
253- return nil, errMovePaneInvalid
257+ return nil, errMoveFloatingWarming
254258 }
255259 if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {
256- return nil, errMovePaneInvalid
260+ return nil, errMoveTooSmall
257261 }
258262 panes := make([]*pane, 0, len(moved.panes)+1)
259263 for _, p := range moved.panes {
@@ -46,7 +46,8 @@ type moveTabCommit struct {
46 err error46 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 errMovePaneInvalid52 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 errMovePaneInvalid62 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 err66+ return errMoveStaleTarget
63 }67 }
64 reservationHeld := true68 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.err161 return commit.err
158 }162 }
159- return errMovePaneInvalid163+ 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, errMovePaneInvalid239+ 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, errMovePaneInvalid244+ 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, errMovePaneInvalid248+ 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, errMovePaneInvalid257+ 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, errMovePaneInvalid260+ 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) {
172172
173173 func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
174174 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
177178 }{
178- {name: "same session", mutate: func(source, _ *session, _ *tab, req *moveTabRequest) {
179+ {name: "same session", wantErr: errMovePaneInvalid, mutate: func(source, _ *session, _ *tab, req *moveTabRequest) {
179180 req.Destination = req.Source
180181 }},
181- {name: "stale incarnation", mutate: func(_, _ *session, _ *tab, req *moveTabRequest) {
182+ {name: "stale incarnation", wantErr: errMoveStaleTarget, mutate: func(_, _ *session, _ *tab, req *moveTabRequest) {
182183 req.Destination.Incarnation[0]++
183184 }},
184- {name: "missing membership", mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) {
185+ {name: "missing membership", wantErr: errMoveStaleTarget, mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) {
185186 source.tabs = source.tabs[1:]
186187 _ = moved
187188 }},
188- {name: "warming floating", mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) {
189+ {name: "warming floating", wantErr: errMoveFloatingWarming, mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) {
189190 moved.floating.state = floatingWarming
190191 moved.floating.generation = 3
191192 }},
192- {name: "destination teardown", mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) {
193+ {name: "destination teardown", wantErr: errMoveStaleTarget, mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) {
193194 destination.teardownMu.Lock()
194195 destination.teardownActive = true
195196 destination.teardownMu.Unlock()
@@ -213,7 +214,9 @@ func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
213214 destination.teardownMu.Lock()
214215 destination.teardownActive = false
215216 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)
217220 require.Equal(t, beforeDestination, destination.tabs)
218221 require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot())
219222 })
@@ -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 string175+ 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.Source180 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 _ = moved187 _ = 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 = floatingWarming190 moved.floating.state = floatingWarming
190 moved.floating.generation = 3191 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 = true195 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 = false215 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) {
289289 destinationTree: layout.NewTree("pane-1"),
290290 destinationSize: domain.Size{Cols: 41, Rows: 2},
291291 destinationNext: 2,
292- wantErr: errMovePaneFloatingSibling,
292+ wantErr: errMoveFloatingWarming,
293293 },
294294 {
295295 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 ...
343343 }
344344 if err != nil {
345345 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
346- d.reportError(sess, err)
346+ d.reportError(sess, paletteCommandNoticeError(cmd, err))
347347 } else {
348348 d.recordPaletteUse(cmd.Code)
349349 }
350350 }
351351
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+
352359 func paletteArgs(query string, cmd command.Command) []string {
353360 action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
354361 if !ok {
@@ -448,6 +455,31 @@ func (e paletteExec) ClosePane() error {
448455 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
449456 }
450457
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+
451483 func (e paletteExec) focus(direction layout.Direction) error {
452484 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
453485 }
@@ -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) {
143143 require.Equal(t, domain.NoticeTabSpawn, history[0].Code)
144144 }
145145
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+
146198 func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
147199 p, release := newBlockingPTY(t)
148200 defer release()
@@ -630,12 +682,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
630682 for i, cmd := range commands {
631683 codes[i] = cmd.Code
632684 }
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)
634686 }
635687
636688 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
637689 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"}
639691
640692 var wg sync.WaitGroup
641693 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.Code683 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.WaitGroup692 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 (
1414 "github.com/bnema/vev/pkg/renderer"
1515 )
1616
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{}}
2146
2247 type pickerIntent uint8
2348
@@ -37,6 +62,7 @@ type moveSourceLocator struct {
3762 Session moveSessionLocator
3863 TabID domain.TabStableID
3964 PaneID domain.PaneStableID
65+ Client *attachedClient
4066 }
4167
4268 // enterPicker preserves the existing navigation entry point. Navigation always
@@ -228,9 +254,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
228254 })
229255 var target picker.Target
230256 var ok bool
257+ var intent pickerIntent
258+ var source moveSourceLocator
231259 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
232260 target, ok = rt.picker.Selected()
233261 }
262+ intent, source = rt.pickerIntent, rt.pickerSource
234263 rt.pickerMu.Unlock()
235264
236265 if result.action == 'x' {
@@ -258,11 +287,27 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
258287 if result.changed {
259288 d.registerPreviewForSelection(ac)
260289 }
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 {
263292 d.closePicker(ac)
264293 }
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+ }
266311 var err error
267312 if len(effects) != 0 && effects[0] != nil {
268313 err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
@@ -421,6 +466,31 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
421466 }
422467 }
423468
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+
424494 func (d *Daemon) closePicker(ac *attachedClient) {
425495 ac.overlays.pickerMu.Lock()
426496 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 uint847 type pickerIntent uint8
23 48
@@ -37,6 +62,7 @@ type moveSourceLocator struct {
37 Session moveSessionLocator62 Session moveSessionLocator
38 TabID domain.TabStableID63 TabID domain.TabStableID
39 PaneID domain.PaneStableID64 PaneID domain.PaneStableID
65+ Client *attachedClient
40 }66 }
41 67
42 // enterPicker preserves the existing navigation entry point. Navigation always68 // 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.Target255 var target picker.Target
230 var ok bool256 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') && ok290+ 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 error311 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 = nil496 ac.overlays.picker = nil

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close