detached 832e1eb

Merge pull request #141 from bnema/feat/directional-pane-consume-expel

feat(panes): add directional consume and expel
commit
832e1ebcd5341ca68d652840747a5e39c353a1d8
author
bnema · · 2026-07-27 17:41 UTC
committer
GitHub · · 2026-07-27 17:41 UTC
merge of
8c7be4b + 26241db · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
26 files · +2090 -133
M README.md +1 -1
@@ -50,7 +50,7 @@ No prefix key; everything is Alt.
5050 | Alt+h/j/k/l, Alt+Arrow | focus pane |
5151 | Alt+a | jump to a session needing attention |
5252
53-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
53+The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
5454
5555 ## Remote attach
5656
@@ -50,7 +50,7 @@ No prefix key; everything is Alt.
50 | Alt+h/j/k/l, Alt+Arrow | focus pane |50 | Alt+h/j/k/l, Alt+Arrow | focus pane |
51 | Alt+a | jump to a session needing attention |51 | Alt+a | jump to a session needing attention |
52 52
53-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.53+The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
54 54
55 ## Remote attach55 ## Remote attach
56 56
M docs/configuration.md +13 -0
@@ -34,6 +34,9 @@ focus-pane-left = alt+h
3434 focus-pane-right = alt+l
3535 focus-pane-up = alt+k
3636 focus-pane-down = alt+j
37+# Optional pane rearrangement actions are unbound by default.
38+# consume-or-expel-pane-left = alt+H
39+# consume-or-expel-pane-right = alt+L
3740 switch-tab-1 = alt+1
3841 # ... through switch-tab-9 = alt+9
3942
@@ -53,6 +56,8 @@ code.split-right = SPR
5356 code.split-left = SPL
5457 code.split-up = SPU
5558 code.split-down = SPD
59+code.consume-or-expel-pane-left = CEL
60+code.consume-or-expel-pane-right = CER
5661 code.stack-pane = STP
5762 code.toggle-stack = TST
5863 code.close-pane = CLP
@@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b
96101
97102 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
98103
104+`alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev.
105+
106+## Pane consume or expel
107+
108+The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`.
109+
110+A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported.
111+
99112 ## Navigation overflow
100113
101114 Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.
@@ -34,6 +34,9 @@ focus-pane-left = alt+h
34 focus-pane-right = alt+l34 focus-pane-right = alt+l
35 focus-pane-up = alt+k35 focus-pane-up = alt+k
36 focus-pane-down = alt+j36 focus-pane-down = alt+j
37+# Optional pane rearrangement actions are unbound by default.
38+# consume-or-expel-pane-left = alt+H
39+# consume-or-expel-pane-right = alt+L
37 switch-tab-1 = alt+140 switch-tab-1 = alt+1
38 # ... through switch-tab-9 = alt+941 # ... through switch-tab-9 = alt+9
39 42
@@ -53,6 +56,8 @@ code.split-right = SPR
53 code.split-left = SPL56 code.split-left = SPL
54 code.split-up = SPU57 code.split-up = SPU
55 code.split-down = SPD58 code.split-down = SPD
59+code.consume-or-expel-pane-left = CEL
60+code.consume-or-expel-pane-right = CER
56 code.stack-pane = STP61 code.stack-pane = STP
57 code.toggle-stack = TST62 code.toggle-stack = TST
58 code.close-pane = CLP63 code.close-pane = CLP
@@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b
96 101
97 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.102 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
98 103
104+`alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev.
105+
106+## Pane consume or expel
107+
108+The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`.
109+
110+A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported.
111+
99 ## Navigation overflow112 ## Navigation overflow
100 113
101 Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.114 Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.
M internal/app/cmd_test.go +13 -3
@@ -25,6 +25,8 @@ func TestParseCmdArgs(t *testing.T) {
2525 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
2626 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
2727 {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
28+ {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false},
29+ {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false},
2830 {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
2931 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
3032 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
@@ -93,7 +95,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
9395 if called {
9496 t.Fatal("help dialed daemon")
9597 }
96- for _, want := range []string{"usage: vev cmd", "split-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {
98+ for _, want := range []string{"usage: vev cmd", "split-right", "consume-or-expel-pane-left", "consume-or-expel-pane-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {
9799 if !strings.Contains(out.String(), want) {
98100 t.Fatalf("help %q missing %q", out.String(), want)
99101 }
@@ -105,8 +107,16 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
105107 }
106108 }
107109
108-func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) {
109- for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
110+func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
111+ for _, slug := range []string{
112+ "grow-pane-width",
113+ "shrink-pane-width",
114+ "grow-pane-height",
115+ "shrink-pane-height",
116+ "equalize-panes",
117+ "consume-or-expel-pane-left",
118+ "consume-or-expel-pane-right",
119+ } {
110120 t.Run(slug, func(t *testing.T) {
111121 invocation, err := parseArgs([]string{"cmd", "--self", slug})
112122 require.NoError(t, err)
@@ -25,6 +25,8 @@ func TestParseCmdArgs(t *testing.T) {
25 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},25 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
26 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},26 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
27 {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},27 {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
28+ {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false},
29+ {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false},
28 {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},30 {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
29 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},31 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
30 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},32 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
@@ -93,7 +95,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
93 if called {95 if called {
94 t.Fatal("help dialed daemon")96 t.Fatal("help dialed daemon")
95 }97 }
96- for _, want := range []string{"usage: vev cmd", "split-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {98+ for _, want := range []string{"usage: vev cmd", "split-right", "consume-or-expel-pane-left", "consume-or-expel-pane-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {
97 if !strings.Contains(out.String(), want) {99 if !strings.Contains(out.String(), want) {
98 t.Fatalf("help %q missing %q", out.String(), want)100 t.Fatalf("help %q missing %q", out.String(), want)
99 }101 }
@@ -105,8 +107,16 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
105 }107 }
106 }108 }
107 109
108-func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) {110+func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
109- for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {111+ for _, slug := range []string{
112+ "grow-pane-width",
113+ "shrink-pane-width",
114+ "grow-pane-height",
115+ "shrink-pane-height",
116+ "equalize-panes",
117+ "consume-or-expel-pane-left",
118+ "consume-or-expel-pane-right",
119+ } {
110 t.Run(slug, func(t *testing.T) {120 t.Run(slug, func(t *testing.T) {
111 invocation, err := parseArgs([]string{"cmd", "--self", slug})121 invocation, err := parseArgs([]string{"cmd", "--self", slug})
112 require.NoError(t, err)122 require.NoError(t, err)
M internal/usecase/command/command.go +4 -0
@@ -44,6 +44,8 @@ type Context interface {
4444 SplitLeft() error
4545 SplitUp() error
4646 SplitDown() error
47+ ConsumeOrExpelPaneLeft() error
48+ ConsumeOrExpelPaneRight() error
4749 StackPane() error
4850 ToggleStack() error
4951 ToggleFloatingPane() error
@@ -117,6 +119,8 @@ type ControlContext interface {
117119 SplitLeft() error
118120 SplitUp() error
119121 SplitDown() error
122+ ConsumeOrExpelPaneLeft() error
123+ ConsumeOrExpelPaneRight() error
120124 StackPane() error
121125 ToggleStack() error
122126 GrowPaneWidth() error
@@ -44,6 +44,8 @@ type Context interface {
44 SplitLeft() error44 SplitLeft() error
45 SplitUp() error45 SplitUp() error
46 SplitDown() error46 SplitDown() error
47+ ConsumeOrExpelPaneLeft() error
48+ ConsumeOrExpelPaneRight() error
47 StackPane() error49 StackPane() error
48 ToggleStack() error50 ToggleStack() error
49 ToggleFloatingPane() error51 ToggleFloatingPane() error
@@ -117,6 +119,8 @@ type ControlContext interface {
117 SplitLeft() error119 SplitLeft() error
118 SplitUp() error120 SplitUp() error
119 SplitDown() error121 SplitDown() error
122+ ConsumeOrExpelPaneLeft() error
123+ ConsumeOrExpelPaneRight() error
120 StackPane() error124 StackPane() error
121 ToggleStack() error125 ToggleStack() error
122 GrowPaneWidth() error126 GrowPaneWidth() error
M internal/usecase/command/context_mock_test.go +88 -0
@@ -167,6 +167,94 @@ func (_c *MockContext_CloseTab_Call) RunAndReturn(run func() error) *MockContext
167167 return _c
168168 }
169169
170+// ConsumeOrExpelPaneLeft provides a mock function for the type MockContext
171+func (_mock *MockContext) ConsumeOrExpelPaneLeft() error {
172+ ret := _mock.Called()
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for ConsumeOrExpelPaneLeft")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func() error); ok {
180+ r0 = returnFunc()
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockContext_ConsumeOrExpelPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneLeft'
188+type MockContext_ConsumeOrExpelPaneLeft_Call struct {
189+ *mock.Call
190+}
191+
192+// ConsumeOrExpelPaneLeft is a helper method to define mock.On call
193+func (_e *MockContext_Expecter) ConsumeOrExpelPaneLeft() *MockContext_ConsumeOrExpelPaneLeft_Call {
194+ return &MockContext_ConsumeOrExpelPaneLeft_Call{Call: _e.mock.On("ConsumeOrExpelPaneLeft")}
195+}
196+
197+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneLeft_Call {
198+ _c.Call.Run(func(args mock.Arguments) {
199+ run()
200+ })
201+ return _c
202+}
203+
204+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Return(err error) *MockContext_ConsumeOrExpelPaneLeft_Call {
205+ _c.Call.Return(err)
206+ return _c
207+}
208+
209+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneLeft_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// ConsumeOrExpelPaneRight provides a mock function for the type MockContext
215+func (_mock *MockContext) ConsumeOrExpelPaneRight() error {
216+ ret := _mock.Called()
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for ConsumeOrExpelPaneRight")
220+ }
221+
222+ var r0 error
223+ if returnFunc, ok := ret.Get(0).(func() error); ok {
224+ r0 = returnFunc()
225+ } else {
226+ r0 = ret.Error(0)
227+ }
228+ return r0
229+}
230+
231+// MockContext_ConsumeOrExpelPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneRight'
232+type MockContext_ConsumeOrExpelPaneRight_Call struct {
233+ *mock.Call
234+}
235+
236+// ConsumeOrExpelPaneRight is a helper method to define mock.On call
237+func (_e *MockContext_Expecter) ConsumeOrExpelPaneRight() *MockContext_ConsumeOrExpelPaneRight_Call {
238+ return &MockContext_ConsumeOrExpelPaneRight_Call{Call: _e.mock.On("ConsumeOrExpelPaneRight")}
239+}
240+
241+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneRight_Call {
242+ _c.Call.Run(func(args mock.Arguments) {
243+ run()
244+ })
245+ return _c
246+}
247+
248+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Return(err error) *MockContext_ConsumeOrExpelPaneRight_Call {
249+ _c.Call.Return(err)
250+ return _c
251+}
252+
253+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneRight_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
257+
170258 // CreateSession provides a mock function for the type MockContext
171259 func (_mock *MockContext) CreateSession() error {
172260 ret := _mock.Called()
@@ -167,6 +167,94 @@ func (_c *MockContext_CloseTab_Call) RunAndReturn(run func() error) *MockContext
167 return _c167 return _c
168 }168 }
169 169
170+// ConsumeOrExpelPaneLeft provides a mock function for the type MockContext
171+func (_mock *MockContext) ConsumeOrExpelPaneLeft() error {
172+ ret := _mock.Called()
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for ConsumeOrExpelPaneLeft")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func() error); ok {
180+ r0 = returnFunc()
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockContext_ConsumeOrExpelPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneLeft'
188+type MockContext_ConsumeOrExpelPaneLeft_Call struct {
189+ *mock.Call
190+}
191+
192+// ConsumeOrExpelPaneLeft is a helper method to define mock.On call
193+func (_e *MockContext_Expecter) ConsumeOrExpelPaneLeft() *MockContext_ConsumeOrExpelPaneLeft_Call {
194+ return &MockContext_ConsumeOrExpelPaneLeft_Call{Call: _e.mock.On("ConsumeOrExpelPaneLeft")}
195+}
196+
197+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneLeft_Call {
198+ _c.Call.Run(func(args mock.Arguments) {
199+ run()
200+ })
201+ return _c
202+}
203+
204+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Return(err error) *MockContext_ConsumeOrExpelPaneLeft_Call {
205+ _c.Call.Return(err)
206+ return _c
207+}
208+
209+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneLeft_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// ConsumeOrExpelPaneRight provides a mock function for the type MockContext
215+func (_mock *MockContext) ConsumeOrExpelPaneRight() error {
216+ ret := _mock.Called()
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for ConsumeOrExpelPaneRight")
220+ }
221+
222+ var r0 error
223+ if returnFunc, ok := ret.Get(0).(func() error); ok {
224+ r0 = returnFunc()
225+ } else {
226+ r0 = ret.Error(0)
227+ }
228+ return r0
229+}
230+
231+// MockContext_ConsumeOrExpelPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneRight'
232+type MockContext_ConsumeOrExpelPaneRight_Call struct {
233+ *mock.Call
234+}
235+
236+// ConsumeOrExpelPaneRight is a helper method to define mock.On call
237+func (_e *MockContext_Expecter) ConsumeOrExpelPaneRight() *MockContext_ConsumeOrExpelPaneRight_Call {
238+ return &MockContext_ConsumeOrExpelPaneRight_Call{Call: _e.mock.On("ConsumeOrExpelPaneRight")}
239+}
240+
241+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneRight_Call {
242+ _c.Call.Run(func(args mock.Arguments) {
243+ run()
244+ })
245+ return _c
246+}
247+
248+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Return(err error) *MockContext_ConsumeOrExpelPaneRight_Call {
249+ _c.Call.Return(err)
250+ return _c
251+}
252+
253+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneRight_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
257+
170 // CreateSession provides a mock function for the type MockContext258 // CreateSession provides a mock function for the type MockContext
171 func (_mock *MockContext) CreateSession() error {259 func (_mock *MockContext) CreateSession() error {
172 ret := _mock.Called()260 ret := _mock.Called()
M internal/usecase/command/registry.go +2 -0
@@ -12,6 +12,8 @@ func Registry() []Command {
1212 paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),
1313 paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }),
1414 paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }),
15+ paletteControl("consume-or-expel-pane-left", "CEL", "Consume or expel pane left", "Move the focused pane into or out of the column on the left", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneLeft() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneLeft() }),
16+ paletteControl("consume-or-expel-pane-right", "CER", "Consume or expel pane right", "Move the focused pane into or out of the column on the right", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneRight() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneRight() }),
1517 paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }),
1618 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() }),
1719 paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),
@@ -12,6 +12,8 @@ func Registry() []Command {
12 paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),12 paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),
13 paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }),13 paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }),
14 paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }),14 paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }),
15+ paletteControl("consume-or-expel-pane-left", "CEL", "Consume or expel pane left", "Move the focused pane into or out of the column on the left", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneLeft() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneLeft() }),
16+ paletteControl("consume-or-expel-pane-right", "CER", "Consume or expel pane right", "Move the focused pane into or out of the column on the right", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneRight() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneRight() }),
15 paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }),17 paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }),
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() }),18 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() }),19 paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),
M internal/usecase/command/registry_test.go +11 -2
@@ -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", "CEL", "CER", "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"}
1313
1414 if len(commands) != len(wantCodes) {
1515 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -45,6 +45,8 @@ func TestBySlugExactMatch(t *testing.T) {
4545 ok bool
4646 }{
4747 {slug: "split-right", ok: true},
48+ {slug: "consume-or-expel-pane-left", ok: true},
49+ {slug: "consume-or-expel-pane-right", ok: true},
4850 {slug: "SPLIT-RIGHT", ok: false},
4951 {slug: "toast", ok: true},
5052 {slug: "list-panes", ok: true},
@@ -62,7 +64,8 @@ func TestBySlugExactMatch(t *testing.T) {
6264 func TestRegistryControlMetadata(t *testing.T) {
6365 scriptable := map[string]TargetKind{
6466 "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane,
65- "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,
67+ "split-down": TargetPane, "consume-or-expel-pane-left": TargetPane,
68+ "consume-or-expel-pane-right": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,
6669 "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,
6770 "focus-pane-up": TargetPane, "focus-pane-down": TargetPane,
6871 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
@@ -146,6 +149,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
146149 }{
147150 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
148151 {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},
152+ {name: "consume or expel left delegates", slug: "consume-or-expel-pane-left", wantCall: "consume-or-expel-pane-left"},
153+ {name: "consume or expel right delegates", slug: "consume-or-expel-pane-right", wantCall: "consume-or-expel-pane-right"},
149154 {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},
150155 {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"},
151156 {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"},
@@ -192,6 +197,8 @@ func (s *controlSpy) SplitRight() error { return s.record("split
192197 func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
193198 func (s *controlSpy) SplitUp() error { return s.record("split-up") }
194199 func (s *controlSpy) SplitDown() error { return s.record("split-down") }
200+func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") }
201+func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") }
195202 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
196203 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
197204 func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
@@ -285,6 +292,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
285292 {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},
286293 {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},
287294 {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
295+ {code: "CEL", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneLeft().Return(nil).Once() }},
296+ {code: "CER", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneRight().Return(nil).Once() }},
288297 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
289298 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
290299 {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().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", "CEL", "CER", "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"}
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))
@@ -45,6 +45,8 @@ func TestBySlugExactMatch(t *testing.T) {
45 ok bool45 ok bool
46 }{46 }{
47 {slug: "split-right", ok: true},47 {slug: "split-right", ok: true},
48+ {slug: "consume-or-expel-pane-left", ok: true},
49+ {slug: "consume-or-expel-pane-right", ok: true},
48 {slug: "SPLIT-RIGHT", ok: false},50 {slug: "SPLIT-RIGHT", ok: false},
49 {slug: "toast", ok: true},51 {slug: "toast", ok: true},
50 {slug: "list-panes", ok: true},52 {slug: "list-panes", ok: true},
@@ -62,7 +64,8 @@ func TestBySlugExactMatch(t *testing.T) {
62 func TestRegistryControlMetadata(t *testing.T) {64 func TestRegistryControlMetadata(t *testing.T) {
63 scriptable := map[string]TargetKind{65 scriptable := map[string]TargetKind{
64 "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane,66 "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane,
65- "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,67+ "split-down": TargetPane, "consume-or-expel-pane-left": TargetPane,
68+ "consume-or-expel-pane-right": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,
66 "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,69 "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,
67 "focus-pane-up": TargetPane, "focus-pane-down": TargetPane,70 "focus-pane-up": TargetPane, "focus-pane-down": TargetPane,
68 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,71 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
@@ -146,6 +149,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
146 }{149 }{
147 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},150 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
148 {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},151 {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},
152+ {name: "consume or expel left delegates", slug: "consume-or-expel-pane-left", wantCall: "consume-or-expel-pane-left"},
153+ {name: "consume or expel right delegates", slug: "consume-or-expel-pane-right", wantCall: "consume-or-expel-pane-right"},
149 {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},154 {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},
150 {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"},155 {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"},
151 {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"},156 {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"},
@@ -192,6 +197,8 @@ func (s *controlSpy) SplitRight() error { return s.record("split
192 func (s *controlSpy) SplitLeft() error { return s.record("split-left") }197 func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
193 func (s *controlSpy) SplitUp() error { return s.record("split-up") }198 func (s *controlSpy) SplitUp() error { return s.record("split-up") }
194 func (s *controlSpy) SplitDown() error { return s.record("split-down") }199 func (s *controlSpy) SplitDown() error { return s.record("split-down") }
200+func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") }
201+func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") }
195 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }202 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
196 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }203 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
197 func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }204 func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
@@ -285,6 +292,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
285 {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},292 {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},
286 {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},293 {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},
287 {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},294 {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
295+ {code: "CEL", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneLeft().Return(nil).Once() }},
296+ {code: "CER", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneRight().Return(nil).Once() }},
288 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},297 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
289 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},298 {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() }},299 {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},
M internal/usecase/daemon/control.go +20 -2
@@ -206,6 +206,7 @@ const (
206206 daemonActionRenameTab
207207 daemonActionResizePane
208208 daemonActionEqualizePanes
209+ daemonActionConsumeOrExpelPane
209210 )
210211
211212 type daemonActionTarget struct {
@@ -289,6 +290,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
289290 return a.d.resizePane(target, request.axis, request.delta)
290291 case daemonActionEqualizePanes:
291292 return a.d.equalizePanes(target)
293+ case daemonActionConsumeOrExpelPane:
294+ return a.d.consumeOrExpelPane(target, request.direction)
292295 default:
293296 return errors.New("daemon: unknown action")
294297 }
@@ -345,13 +348,19 @@ func (e controlExec) runAction(request daemonActionRequest) error {
345348 runner = daemonActions{d: e.d}
346349 }
347350 err := runner.Run(request)
348- if err == nil && e.actions == nil {
351+ if errors.Is(err, errDaemonActionNoChange) {
352+ return nil
353+ }
354+ if err != nil {
355+ return err
356+ }
357+ if e.actions == nil {
349358 e.sess.mu.Lock()
350359 ac := e.sess.client
351360 e.sess.mu.Unlock()
352361 finishDaemonActionForClient(e.d, request, ac, "control.go")
353362 }
354- return err
363+ return nil
355364 }
356365
357366 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
@@ -400,6 +409,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) }
400409 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
401410 func (e controlExec) SplitUp() error { return e.split(layout.Up) }
402411 func (e controlExec) SplitDown() error { return e.split(layout.Down) }
412+func (e controlExec) consumeOrExpelPane(direction layout.Direction) error {
413+ return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
414+}
415+func (e controlExec) ConsumeOrExpelPaneLeft() error {
416+ return e.consumeOrExpelPane(layout.Left)
417+}
418+func (e controlExec) ConsumeOrExpelPaneRight() error {
419+ return e.consumeOrExpelPane(layout.Right)
420+}
403421 func (e controlExec) StackPane() error {
404422 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
405423 }
@@ -206,6 +206,7 @@ const (
206 daemonActionRenameTab206 daemonActionRenameTab
207 daemonActionResizePane207 daemonActionResizePane
208 daemonActionEqualizePanes208 daemonActionEqualizePanes
209+ daemonActionConsumeOrExpelPane
209 )210 )
210 211
211 type daemonActionTarget struct {212 type daemonActionTarget struct {
@@ -289,6 +290,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
289 return a.d.resizePane(target, request.axis, request.delta)290 return a.d.resizePane(target, request.axis, request.delta)
290 case daemonActionEqualizePanes:291 case daemonActionEqualizePanes:
291 return a.d.equalizePanes(target)292 return a.d.equalizePanes(target)
293+ case daemonActionConsumeOrExpelPane:
294+ return a.d.consumeOrExpelPane(target, request.direction)
292 default:295 default:
293 return errors.New("daemon: unknown action")296 return errors.New("daemon: unknown action")
294 }297 }
@@ -345,13 +348,19 @@ func (e controlExec) runAction(request daemonActionRequest) error {
345 runner = daemonActions{d: e.d}348 runner = daemonActions{d: e.d}
346 }349 }
347 err := runner.Run(request)350 err := runner.Run(request)
348- if err == nil && e.actions == nil {351+ if errors.Is(err, errDaemonActionNoChange) {
352+ return nil
353+ }
354+ if err != nil {
355+ return err
356+ }
357+ if e.actions == nil {
349 e.sess.mu.Lock()358 e.sess.mu.Lock()
350 ac := e.sess.client359 ac := e.sess.client
351 e.sess.mu.Unlock()360 e.sess.mu.Unlock()
352 finishDaemonActionForClient(e.d, request, ac, "control.go")361 finishDaemonActionForClient(e.d, request, ac, "control.go")
353 }362 }
354- return err363+ return nil
355 }364 }
356 365
357 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {366 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
@@ -400,6 +409,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) }
400 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }409 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
401 func (e controlExec) SplitUp() error { return e.split(layout.Up) }410 func (e controlExec) SplitUp() error { return e.split(layout.Up) }
402 func (e controlExec) SplitDown() error { return e.split(layout.Down) }411 func (e controlExec) SplitDown() error { return e.split(layout.Down) }
412+func (e controlExec) consumeOrExpelPane(direction layout.Direction) error {
413+ return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
414+}
415+func (e controlExec) ConsumeOrExpelPaneLeft() error {
416+ return e.consumeOrExpelPane(layout.Left)
417+}
418+func (e controlExec) ConsumeOrExpelPaneRight() error {
419+ return e.consumeOrExpelPane(layout.Right)
420+}
403 func (e controlExec) StackPane() error {421 func (e controlExec) StackPane() error {
404 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})422 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
405 }423 }
M internal/usecase/daemon/control_test.go +92 -21
@@ -16,28 +16,99 @@ import (
1616 )
1717
1818 func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
19- d := newTestDaemon(t, nil, stubClock{})
20- sess := addControlSession(d, "work", "t_work", "p_work")
21- tb := sess.activeTab()
22- tb.mu.Lock()
23- pane := tb.focusedPane()
24- tb.mu.Unlock()
25- spy := &actionRunnerSpy{}
26-
27- require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight())
28- require.NoError(t, (controlExec{
29- d: d, sess: sess, tab: tb, actions: spy,
30- target: daemonActionTarget{session: sess, tab: tb, pane: pane},
31- }).SplitRight())
32-
33- require.Len(t, spy.requests, 2)
34- for _, request := range spy.requests {
35- require.Equal(t, daemonActionSplitPane, request.kind)
36- require.Same(t, sess, request.target.session)
37- require.Same(t, tb, request.target.tab)
38- require.Same(t, pane, request.target.pane)
39- require.Equal(t, layout.Right, request.direction)
19+ tests := []struct {
20+ name string
21+ palette func(paletteExec) error
22+ control func(controlExec) error
23+ kind daemonActionKind
24+ direction layout.Direction
25+ }{
26+ {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right},
27+ {name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left},
28+ {name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right},
4029 }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ d := newTestDaemon(t, nil, stubClock{})
34+ sess := addControlSession(d, "work", "t_work", "p_work")
35+ tb := sess.activeTab()
36+ tb.mu.Lock()
37+ pane := tb.focusedPane()
38+ tb.mu.Unlock()
39+ spy := &actionRunnerSpy{}
40+ target := daemonActionTarget{session: sess, tab: tb, pane: pane}
41+
42+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
43+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
44+
45+ require.Len(t, spy.requests, 2)
46+ for _, request := range spy.requests {
47+ require.Equal(t, tt.kind, request.kind)
48+ require.Same(t, sess, request.target.session)
49+ require.Same(t, tb, request.target.tab)
50+ require.Same(t, pane, request.target.pane)
51+ require.Equal(t, tt.direction, request.direction)
52+ }
53+ })
54+ }
55+}
56+
57+func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
58+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
59+ h.tab.mu.Lock()
60+ h.tab.tree.Focus = "pane-1"
61+ beforeGeneration := h.tab.layoutGeneration
62+ tabStableID := h.tab.stableID
63+ targetStableID := h.panes["pane-3"].stableID
64+ h.tab.mu.Unlock()
65+
66+ result := sendCommand(t, h.daemon, ports.CommandRequest{
67+ Slug: "consume-or-expel-pane-left",
68+ Self: true,
69+ TargetSession: "work",
70+ TargetTab: tabStableID,
71+ TargetPane: targetStableID,
72+ })
73+
74+ require.True(t, result.OK, result.Text)
75+ require.Empty(t, result.Output)
76+ h.session.mu.Lock()
77+ require.Zero(t, h.session.active, "--self must not change the active tab")
78+ h.session.mu.Unlock()
79+ h.tab.mu.Lock()
80+ require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
81+ require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
82+ require.Len(t, h.tab.tree.Root.Children, 2)
83+ h.tab.mu.Unlock()
84+}
85+
86+func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
87+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
88+ ac := &attachedClient{}
89+ ac.setSession(h.session)
90+ h.session.mu.Lock()
91+ h.session.client = ac
92+ h.session.mu.Unlock()
93+ invalidations := make(chan renderInvalidation, 1)
94+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
95+ rc.attach(ac)
96+ h.session.installRenderCoordinator(rc)
97+ before := h.snapshot()
98+
99+ result := sendCommand(t, h.daemon, ports.CommandRequest{
100+ Slug: "consume-or-expel-pane-left",
101+ Self: true,
102+ TargetSession: "work",
103+ TargetTab: h.tab.stableID,
104+ TargetPane: h.panes["pane-1"].stableID,
105+ })
106+
107+ require.True(t, result.OK, result.Text)
108+ require.Empty(t, result.Output)
109+ require.Equal(t, before, h.snapshot())
110+ require.Empty(t, h.daemon.notices.history())
111+ requireNoInvalidation(t, invalidations)
41112 }
42113
43114 type actionRunnerSpy struct {
@@ -16,28 +16,99 @@ import (
16 )16 )
17 17
18 func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {18 func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
19- d := newTestDaemon(t, nil, stubClock{})19+ tests := []struct {
20- sess := addControlSession(d, "work", "t_work", "p_work")20+ name string
21- tb := sess.activeTab()21+ palette func(paletteExec) error
22- tb.mu.Lock()22+ control func(controlExec) error
23- pane := tb.focusedPane()23+ kind daemonActionKind
24- tb.mu.Unlock()24+ direction layout.Direction
25- spy := &actionRunnerSpy{}25+ }{
26-26+ {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right},
27- require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight())27+ {name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left},
28- require.NoError(t, (controlExec{28+ {name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right},
29- d: d, sess: sess, tab: tb, actions: spy,
30- target: daemonActionTarget{session: sess, tab: tb, pane: pane},
31- }).SplitRight())
32-
33- require.Len(t, spy.requests, 2)
34- for _, request := range spy.requests {
35- require.Equal(t, daemonActionSplitPane, request.kind)
36- require.Same(t, sess, request.target.session)
37- require.Same(t, tb, request.target.tab)
38- require.Same(t, pane, request.target.pane)
39- require.Equal(t, layout.Right, request.direction)
40 }29 }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ d := newTestDaemon(t, nil, stubClock{})
34+ sess := addControlSession(d, "work", "t_work", "p_work")
35+ tb := sess.activeTab()
36+ tb.mu.Lock()
37+ pane := tb.focusedPane()
38+ tb.mu.Unlock()
39+ spy := &actionRunnerSpy{}
40+ target := daemonActionTarget{session: sess, tab: tb, pane: pane}
41+
42+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
43+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
44+
45+ require.Len(t, spy.requests, 2)
46+ for _, request := range spy.requests {
47+ require.Equal(t, tt.kind, request.kind)
48+ require.Same(t, sess, request.target.session)
49+ require.Same(t, tb, request.target.tab)
50+ require.Same(t, pane, request.target.pane)
51+ require.Equal(t, tt.direction, request.direction)
52+ }
53+ })
54+ }
55+}
56+
57+func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
58+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
59+ h.tab.mu.Lock()
60+ h.tab.tree.Focus = "pane-1"
61+ beforeGeneration := h.tab.layoutGeneration
62+ tabStableID := h.tab.stableID
63+ targetStableID := h.panes["pane-3"].stableID
64+ h.tab.mu.Unlock()
65+
66+ result := sendCommand(t, h.daemon, ports.CommandRequest{
67+ Slug: "consume-or-expel-pane-left",
68+ Self: true,
69+ TargetSession: "work",
70+ TargetTab: tabStableID,
71+ TargetPane: targetStableID,
72+ })
73+
74+ require.True(t, result.OK, result.Text)
75+ require.Empty(t, result.Output)
76+ h.session.mu.Lock()
77+ require.Zero(t, h.session.active, "--self must not change the active tab")
78+ h.session.mu.Unlock()
79+ h.tab.mu.Lock()
80+ require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
81+ require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
82+ require.Len(t, h.tab.tree.Root.Children, 2)
83+ h.tab.mu.Unlock()
84+}
85+
86+func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
87+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
88+ ac := &attachedClient{}
89+ ac.setSession(h.session)
90+ h.session.mu.Lock()
91+ h.session.client = ac
92+ h.session.mu.Unlock()
93+ invalidations := make(chan renderInvalidation, 1)
94+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
95+ rc.attach(ac)
96+ h.session.installRenderCoordinator(rc)
97+ before := h.snapshot()
98+
99+ result := sendCommand(t, h.daemon, ports.CommandRequest{
100+ Slug: "consume-or-expel-pane-left",
101+ Self: true,
102+ TargetSession: "work",
103+ TargetTab: h.tab.stableID,
104+ TargetPane: h.panes["pane-1"].stableID,
105+ })
106+
107+ require.True(t, result.OK, result.Text)
108+ require.Empty(t, result.Output)
109+ require.Equal(t, before, h.snapshot())
110+ require.Empty(t, h.daemon.notices.history())
111+ requireNoInvalidation(t, invalidations)
41 }112 }
42 113
43 type actionRunnerSpy struct {114 type actionRunnerSpy struct {
M internal/usecase/daemon/input.go +19 -7
@@ -3,6 +3,7 @@ package daemon
33
44 import (
55 "bytes"
6+ "errors"
67 "strconv"
78
89 "github.com/bnema/vev/internal/domain"
@@ -291,14 +292,21 @@ func (h daemonKeyHandler) Action(action keys.Action) {
291292 if sess == nil {
292293 return
293294 }
294- runResizeAction := func(request daemonActionRequest) {
295+ runAction := func(request daemonActionRequest) {
295296 request.target = resolveDaemonActionTarget(sess)
296297 runner := h.actions
297298 if runner == nil {
298299 runner = daemonActions{d: h.d}
299300 }
300301 if err := runner.Run(request); err != nil {
301- h.d.reportError(sess, resizeUserError(err))
302+ if errors.Is(err, errDaemonActionNoChange) {
303+ return
304+ }
305+ var userErr *domain.UserError
306+ if !errors.As(err, &userErr) {
307+ err = resizeUserError(err)
308+ }
309+ h.d.reportError(sess, err)
302310 return
303311 }
304312 if h.actions == nil {
@@ -334,15 +342,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
334342 h.d.reportError(sess, err)
335343 }
336344 case keys.ActionGrowPaneWidth:
337- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
345+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
338346 case keys.ActionShrinkPaneWidth:
339- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
347+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
340348 case keys.ActionGrowPaneHeight:
341- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
349+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
342350 case keys.ActionShrinkPaneHeight:
343- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
351+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
344352 case keys.ActionEqualizePanes:
345- runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes})
353+ runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
354+ case keys.ActionConsumeOrExpelPaneLeft:
355+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
356+ case keys.ActionConsumeOrExpelPaneRight:
357+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
346358 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
347359 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
348360 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
@@ -3,6 +3,7 @@ package daemon
3 3
4 import (4 import (
5 "bytes"5 "bytes"
6+ "errors"
6 "strconv"7 "strconv"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
@@ -291,14 +292,21 @@ func (h daemonKeyHandler) Action(action keys.Action) {
291 if sess == nil {292 if sess == nil {
292 return293 return
293 }294 }
294- runResizeAction := func(request daemonActionRequest) {295+ runAction := func(request daemonActionRequest) {
295 request.target = resolveDaemonActionTarget(sess)296 request.target = resolveDaemonActionTarget(sess)
296 runner := h.actions297 runner := h.actions
297 if runner == nil {298 if runner == nil {
298 runner = daemonActions{d: h.d}299 runner = daemonActions{d: h.d}
299 }300 }
300 if err := runner.Run(request); err != nil {301 if err := runner.Run(request); err != nil {
301- h.d.reportError(sess, resizeUserError(err))302+ if errors.Is(err, errDaemonActionNoChange) {
303+ return
304+ }
305+ var userErr *domain.UserError
306+ if !errors.As(err, &userErr) {
307+ err = resizeUserError(err)
308+ }
309+ h.d.reportError(sess, err)
302 return310 return
303 }311 }
304 if h.actions == nil {312 if h.actions == nil {
@@ -334,15 +342,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
334 h.d.reportError(sess, err)342 h.d.reportError(sess, err)
335 }343 }
336 case keys.ActionGrowPaneWidth:344 case keys.ActionGrowPaneWidth:
337- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})345+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
338 case keys.ActionShrinkPaneWidth:346 case keys.ActionShrinkPaneWidth:
339- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})347+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
340 case keys.ActionGrowPaneHeight:348 case keys.ActionGrowPaneHeight:
341- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})349+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
342 case keys.ActionShrinkPaneHeight:350 case keys.ActionShrinkPaneHeight:
343- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})351+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
344 case keys.ActionEqualizePanes:352 case keys.ActionEqualizePanes:
345- runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes})353+ runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
354+ case keys.ActionConsumeOrExpelPaneLeft:
355+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
356+ case keys.ActionConsumeOrExpelPaneRight:
357+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
346 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,358 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
347 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,359 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
348 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:360 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +102 -0
@@ -26,6 +26,108 @@ import (
2626
2727 // --- test doubles -----------------------------------------------------------
2828
29+func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ binding string
33+ key byte
34+ focus layout.PaneID
35+ wantTarget layout.PaneID
36+ }{
37+ {name: "left", binding: "consume-or-expel-pane-left", key: 'H', focus: "pane-3", wantTarget: "pane-3"},
38+ {name: "right", binding: "consume-or-expel-pane-right", key: 'L', focus: "pane-1", wantTarget: "pane-1"},
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
44+ h.tab.mu.Lock()
45+ h.tab.tree.Focus = tt.focus
46+ beforeGeneration := h.tab.layoutGeneration
47+ h.tab.mu.Unlock()
48+ bindings, warnings := keys.BuildBindings(map[string]string{tt.binding: "alt+" + string(tt.key)})
49+ require.Empty(t, warnings)
50+ h.daemon.bindings.Store(bindings)
51+ ac := &attachedClient{}
52+ ac.setSession(h.session)
53+ ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
54+ h.session.mu.Lock()
55+ h.session.client = ac
56+ h.session.mu.Unlock()
57+ invalidations := make(chan renderInvalidation, 1)
58+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
59+ rc.attach(ac)
60+ h.session.installRenderCoordinator(rc)
61+
62+ h.daemon.handleInput(h.session, ac, []byte{keys.ESC, tt.key})
63+
64+ h.tab.mu.Lock()
65+ require.Equal(t, tt.wantTarget, h.tab.tree.Focus)
66+ require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
67+ require.Len(t, h.tab.tree.Root.Children, 2)
68+ h.tab.mu.Unlock()
69+ awaitInvalidation(t, invalidations)
70+ requireNoInvalidation(t, invalidations)
71+ })
72+ }
73+}
74+
75+func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) {
76+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
77+ bindings, warnings := keys.BuildBindings(map[string]string{"consume-or-expel-pane-left": "alt+H"})
78+ require.Empty(t, warnings)
79+ h.daemon.bindings.Store(bindings)
80+ ac := &attachedClient{}
81+ ac.setSession(h.session)
82+ ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
83+ h.session.mu.Lock()
84+ h.session.client = ac
85+ h.session.mu.Unlock()
86+ invalidations := make(chan renderInvalidation, 1)
87+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
88+ rc.attach(ac)
89+ h.session.installRenderCoordinator(rc)
90+ before := h.snapshot()
91+
92+ h.daemon.handleInput(h.session, ac, []byte{keys.ESC, 'H'})
93+
94+ require.Equal(t, before, h.snapshot())
95+ require.Empty(t, h.daemon.notices.history())
96+ requireNoInvalidation(t, invalidations)
97+}
98+
99+func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
100+ tests := []struct {
101+ name string
102+ action keys.Action
103+ }{
104+ {name: "left", action: keys.ActionConsumeOrExpelPaneLeft},
105+ {name: "right", action: keys.ActionConsumeOrExpelPaneRight},
106+ }
107+
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ d := newTestDaemon(t, nil, stubClock{})
111+ sess := addControlSession(d, "work", "t_work", "p_work")
112+ ac := &attachedClient{}
113+ ac.setSession(sess)
114+ runner := &actionRunnerSpy{err: domain.UserWarn(
115+ domain.NoticeLayoutTooSmall,
116+ "not enough space to rearrange pane",
117+ layout.ErrTooSmall,
118+ )}
119+
120+ daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action)
121+
122+ history := d.notices.history()
123+ require.Len(t, history, 1)
124+ require.Equal(t, domain.NoticeLayoutTooSmall, history[0].Code)
125+ require.Equal(t, domain.NoticeWarn, history[0].Severity)
126+ require.Equal(t, "not enough space to rearrange pane", history[0].Message)
127+ })
128+ }
129+}
130+
29131 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30132 tests := []struct {
31133 name string
@@ -26,6 +26,108 @@ import (
26 26
27 // --- test doubles -----------------------------------------------------------27 // --- test doubles -----------------------------------------------------------
28 28
29+func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ binding string
33+ key byte
34+ focus layout.PaneID
35+ wantTarget layout.PaneID
36+ }{
37+ {name: "left", binding: "consume-or-expel-pane-left", key: 'H', focus: "pane-3", wantTarget: "pane-3"},
38+ {name: "right", binding: "consume-or-expel-pane-right", key: 'L', focus: "pane-1", wantTarget: "pane-1"},
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
44+ h.tab.mu.Lock()
45+ h.tab.tree.Focus = tt.focus
46+ beforeGeneration := h.tab.layoutGeneration
47+ h.tab.mu.Unlock()
48+ bindings, warnings := keys.BuildBindings(map[string]string{tt.binding: "alt+" + string(tt.key)})
49+ require.Empty(t, warnings)
50+ h.daemon.bindings.Store(bindings)
51+ ac := &attachedClient{}
52+ ac.setSession(h.session)
53+ ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
54+ h.session.mu.Lock()
55+ h.session.client = ac
56+ h.session.mu.Unlock()
57+ invalidations := make(chan renderInvalidation, 1)
58+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
59+ rc.attach(ac)
60+ h.session.installRenderCoordinator(rc)
61+
62+ h.daemon.handleInput(h.session, ac, []byte{keys.ESC, tt.key})
63+
64+ h.tab.mu.Lock()
65+ require.Equal(t, tt.wantTarget, h.tab.tree.Focus)
66+ require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
67+ require.Len(t, h.tab.tree.Root.Children, 2)
68+ h.tab.mu.Unlock()
69+ awaitInvalidation(t, invalidations)
70+ requireNoInvalidation(t, invalidations)
71+ })
72+ }
73+}
74+
75+func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) {
76+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
77+ bindings, warnings := keys.BuildBindings(map[string]string{"consume-or-expel-pane-left": "alt+H"})
78+ require.Empty(t, warnings)
79+ h.daemon.bindings.Store(bindings)
80+ ac := &attachedClient{}
81+ ac.setSession(h.session)
82+ ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
83+ h.session.mu.Lock()
84+ h.session.client = ac
85+ h.session.mu.Unlock()
86+ invalidations := make(chan renderInvalidation, 1)
87+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
88+ rc.attach(ac)
89+ h.session.installRenderCoordinator(rc)
90+ before := h.snapshot()
91+
92+ h.daemon.handleInput(h.session, ac, []byte{keys.ESC, 'H'})
93+
94+ require.Equal(t, before, h.snapshot())
95+ require.Empty(t, h.daemon.notices.history())
96+ requireNoInvalidation(t, invalidations)
97+}
98+
99+func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
100+ tests := []struct {
101+ name string
102+ action keys.Action
103+ }{
104+ {name: "left", action: keys.ActionConsumeOrExpelPaneLeft},
105+ {name: "right", action: keys.ActionConsumeOrExpelPaneRight},
106+ }
107+
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ d := newTestDaemon(t, nil, stubClock{})
111+ sess := addControlSession(d, "work", "t_work", "p_work")
112+ ac := &attachedClient{}
113+ ac.setSession(sess)
114+ runner := &actionRunnerSpy{err: domain.UserWarn(
115+ domain.NoticeLayoutTooSmall,
116+ "not enough space to rearrange pane",
117+ layout.ErrTooSmall,
118+ )}
119+
120+ daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action)
121+
122+ history := d.notices.history()
123+ require.Len(t, history, 1)
124+ require.Equal(t, domain.NoticeLayoutTooSmall, history[0].Code)
125+ require.Equal(t, domain.NoticeWarn, history[0].Severity)
126+ require.Equal(t, "not enough space to rearrange pane", history[0].Message)
127+ })
128+ }
129+}
130+
29 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {131 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30 tests := []struct {132 tests := []struct {
31 name string133 name string
A internal/usecase/daemon/layout_mutation.go +80 -0
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// mutateTargetLayout preserves the publication semantics used by resize and
9+// equalize mutations, which always publish a successful candidate.
10+func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
11+ _, err := d.mutateTargetLayoutChanged(target, requirePane, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
12+ if err := mutate(candidate, area); err != nil {
13+ return false, err
14+ }
15+ return true, nil
16+ })
17+ return err
18+}
19+
20+// mutateTargetLayoutChanged validates an explicit target and publishes the
21+// candidate only when mutate reports a change. State locks are released before
22+// applying pane geometry so PTY.Resize never runs under daemon state locks.
23+func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) (bool, error)) (bool, error) {
24+ if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
25+ return false, layout.ErrNotFound
26+ }
27+
28+ d.mu.Lock()
29+ if d.sessions[target.session.id] != target.session {
30+ d.mu.Unlock()
31+ return false, layout.ErrNotFound
32+ }
33+ target.session.mu.Lock()
34+ foundTab := false
35+ for _, tb := range target.session.tabs {
36+ if tb == target.tab {
37+ foundTab = true
38+ break
39+ }
40+ }
41+ if !foundTab {
42+ target.session.mu.Unlock()
43+ d.mu.Unlock()
44+ return false, layout.ErrNotFound
45+ }
46+ target.tab.mu.Lock()
47+ if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
48+ target.tab.mu.Unlock()
49+ target.session.mu.Unlock()
50+ d.mu.Unlock()
51+ return false, layout.ErrNotFound
52+ }
53+
54+ candidate := target.tab.tree.Clone()
55+ area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
56+ changed, err := mutate(candidate, area)
57+ if err != nil {
58+ target.tab.mu.Unlock()
59+ target.session.mu.Unlock()
60+ d.mu.Unlock()
61+ return false, err
62+ }
63+ if !changed {
64+ target.tab.mu.Unlock()
65+ target.session.mu.Unlock()
66+ d.mu.Unlock()
67+ return false, nil
68+ }
69+
70+ target.tab.tree = candidate
71+ target.tab.bumpLayoutGenerationLocked()
72+ target.tab.mu.Unlock()
73+ target.session.mu.Unlock()
74+ d.mu.Unlock()
75+
76+ if !d.applyTabLayout(target.session, target.tab) {
77+ return false, layout.ErrNotFound
78+ }
79+ return true, nil
80+}
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// mutateTargetLayout preserves the publication semantics used by resize and
9+// equalize mutations, which always publish a successful candidate.
10+func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
11+ _, err := d.mutateTargetLayoutChanged(target, requirePane, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
12+ if err := mutate(candidate, area); err != nil {
13+ return false, err
14+ }
15+ return true, nil
16+ })
17+ return err
18+}
19+
20+// mutateTargetLayoutChanged validates an explicit target and publishes the
21+// candidate only when mutate reports a change. State locks are released before
22+// applying pane geometry so PTY.Resize never runs under daemon state locks.
23+func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) (bool, error)) (bool, error) {
24+ if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
25+ return false, layout.ErrNotFound
26+ }
27+
28+ d.mu.Lock()
29+ if d.sessions[target.session.id] != target.session {
30+ d.mu.Unlock()
31+ return false, layout.ErrNotFound
32+ }
33+ target.session.mu.Lock()
34+ foundTab := false
35+ for _, tb := range target.session.tabs {
36+ if tb == target.tab {
37+ foundTab = true
38+ break
39+ }
40+ }
41+ if !foundTab {
42+ target.session.mu.Unlock()
43+ d.mu.Unlock()
44+ return false, layout.ErrNotFound
45+ }
46+ target.tab.mu.Lock()
47+ if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
48+ target.tab.mu.Unlock()
49+ target.session.mu.Unlock()
50+ d.mu.Unlock()
51+ return false, layout.ErrNotFound
52+ }
53+
54+ candidate := target.tab.tree.Clone()
55+ area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
56+ changed, err := mutate(candidate, area)
57+ if err != nil {
58+ target.tab.mu.Unlock()
59+ target.session.mu.Unlock()
60+ d.mu.Unlock()
61+ return false, err
62+ }
63+ if !changed {
64+ target.tab.mu.Unlock()
65+ target.session.mu.Unlock()
66+ d.mu.Unlock()
67+ return false, nil
68+ }
69+
70+ target.tab.tree = candidate
71+ target.tab.bumpLayoutGenerationLocked()
72+ target.tab.mu.Unlock()
73+ target.session.mu.Unlock()
74+ d.mu.Unlock()
75+
76+ if !d.applyTabLayout(target.session, target.tab) {
77+ return false, layout.ErrNotFound
78+ }
79+ return true, nil
80+}
M internal/usecase/daemon/palette.go +28 -8
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "errors"
45 "sort"
56 "time"
67
@@ -304,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
304305 return
305306 }
306307 sess.dispatchMu.Lock()
307- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args)
308+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, redrawClosedPalette: true}, args)
308309 sess.dispatchMu.Unlock()
309310 if err != nil {
310311 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
@@ -351,11 +352,12 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
351352 }
352353
353354 type paletteExec struct {
354- d *Daemon
355- sess *session
356- ac *attachedClient
357- recent []recentSession
358- actions daemonActionRunner
355+ d *Daemon
356+ sess *session
357+ ac *attachedClient
358+ recent []recentSession
359+ actions daemonActionRunner
360+ redrawClosedPalette bool
359361 }
360362
361363 func (e paletteExec) runAction(request daemonActionRequest) error {
@@ -367,10 +369,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error {
367369 runner = daemonActions{d: e.d}
368370 }
369371 err := runner.Run(request)
370- if err == nil && e.actions == nil {
372+ if errors.Is(err, errDaemonActionNoChange) {
373+ if e.redrawClosedPalette {
374+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
375+ }
376+ return nil
377+ }
378+ if err != nil {
379+ return err
380+ }
381+ if e.actions == nil {
371382 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
372383 }
373- return err
384+ return nil
374385 }
375386
376387 func (e paletteExec) CreateTab() error {
@@ -396,6 +407,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
396407 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
397408 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
398409 func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
410+func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error {
411+ return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
412+}
413+func (e paletteExec) ConsumeOrExpelPaneLeft() error {
414+ return e.consumeOrExpelPane(layout.Left)
415+}
416+func (e paletteExec) ConsumeOrExpelPaneRight() error {
417+ return e.consumeOrExpelPane(layout.Right)
418+}
399419
400420 func (e paletteExec) StackPane() error {
401421 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
4 "sort"5 "sort"
5 "time"6 "time"
6 7
@@ -304,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
304 return305 return
305 }306 }
306 sess.dispatchMu.Lock()307 sess.dispatchMu.Lock()
307- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args)308+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, redrawClosedPalette: true}, args)
308 sess.dispatchMu.Unlock()309 sess.dispatchMu.Unlock()
309 if err != nil {310 if err != nil {
310 d.log.Error("palette command failed", "err", err, "command", cmd.Code)311 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
@@ -351,11 +352,12 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
351 }352 }
352 353
353 type paletteExec struct {354 type paletteExec struct {
354- d *Daemon355+ d *Daemon
355- sess *session356+ sess *session
356- ac *attachedClient357+ ac *attachedClient
357- recent []recentSession358+ recent []recentSession
358- actions daemonActionRunner359+ actions daemonActionRunner
360+ redrawClosedPalette bool
359 }361 }
360 362
361 func (e paletteExec) runAction(request daemonActionRequest) error {363 func (e paletteExec) runAction(request daemonActionRequest) error {
@@ -367,10 +369,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error {
367 runner = daemonActions{d: e.d}369 runner = daemonActions{d: e.d}
368 }370 }
369 err := runner.Run(request)371 err := runner.Run(request)
370- if err == nil && e.actions == nil {372+ if errors.Is(err, errDaemonActionNoChange) {
373+ if e.redrawClosedPalette {
374+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
375+ }
376+ return nil
377+ }
378+ if err != nil {
379+ return err
380+ }
381+ if e.actions == nil {
371 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")382 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
372 }383 }
373- return err384+ return nil
374 }385 }
375 386
376 func (e paletteExec) CreateTab() error {387 func (e paletteExec) CreateTab() error {
@@ -396,6 +407,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
396 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }407 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
397 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }408 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
398 func (e paletteExec) SplitDown() error { return e.split(layout.Down) }409 func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
410+func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error {
411+ return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
412+}
413+func (e paletteExec) ConsumeOrExpelPaneLeft() error {
414+ return e.consumeOrExpelPane(layout.Left)
415+}
416+func (e paletteExec) ConsumeOrExpelPaneRight() error {
417+ return e.consumeOrExpelPane(layout.Right)
418+}
399 419
400 func (e paletteExec) StackPane() error {420 func (e paletteExec) StackPane() error {
401 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})421 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
M internal/usecase/daemon/palette_test.go +12 -4
@@ -409,14 +409,22 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
409409 }
410410 d.handleInput(sess, ac, []byte("\x1b "))
411411 awaitFrame(t, sends, ports.MsgOutput)
412- go d.handleInput(sess, ac, []byte("JRS 1\r"))
413- <-validated
412+ inputHandled := make(chan struct{})
413+ go func() {
414+ d.handleInput(sess, ac, []byte("JRS 1\r"))
415+ close(inputHandled)
416+ }()
417+ awaitTestCompletion(t, validated, "JRS did not validate its captured target")
414418 d.mu.Lock()
415419 delete(d.sessions, target.id)
416420 d.mu.Unlock()
417421 close(releaseHandoff)
418422
419- awaitFrame(t, sends, ports.MsgOutput)
423+ // switchToTarget repaints its failed hand-off before handlePaletteInput
424+ // records the generation-safe feedback and schedules the feedback repaint.
425+ // Wait for the input transaction, rather than mistaking that intermediate
426+ // repaint for publication of the final palette state.
427+ awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete")
420428 require.True(t, ac.overlays.paletteActive())
421429 ac.overlays.paletteMu.Lock()
422430 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
@@ -617,7 +625,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
617625 for i, cmd := range commands {
618626 codes[i] = cmd.Code
619627 }
620- 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)
628+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
621629 }
622630
623631 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
@@ -409,14 +409,22 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
409 }409 }
410 d.handleInput(sess, ac, []byte("\x1b "))410 d.handleInput(sess, ac, []byte("\x1b "))
411 awaitFrame(t, sends, ports.MsgOutput)411 awaitFrame(t, sends, ports.MsgOutput)
412- go d.handleInput(sess, ac, []byte("JRS 1\r"))412+ inputHandled := make(chan struct{})
413- <-validated413+ go func() {
414+ d.handleInput(sess, ac, []byte("JRS 1\r"))
415+ close(inputHandled)
416+ }()
417+ awaitTestCompletion(t, validated, "JRS did not validate its captured target")
414 d.mu.Lock()418 d.mu.Lock()
415 delete(d.sessions, target.id)419 delete(d.sessions, target.id)
416 d.mu.Unlock()420 d.mu.Unlock()
417 close(releaseHandoff)421 close(releaseHandoff)
418 422
419- awaitFrame(t, sends, ports.MsgOutput)423+ // switchToTarget repaints its failed hand-off before handlePaletteInput
424+ // records the generation-safe feedback and schedules the feedback repaint.
425+ // Wait for the input transaction, rather than mistaking that intermediate
426+ // repaint for publication of the final palette state.
427+ awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete")
420 require.True(t, ac.overlays.paletteActive())428 require.True(t, ac.overlays.paletteActive())
421 ac.overlays.paletteMu.Lock()429 ac.overlays.paletteMu.Lock()
422 require.Equal(t, "JRS 1", ac.overlays.palette.Query())430 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
@@ -617,7 +625,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
617 for i, cmd := range commands {625 for i, cmd := range commands {
618 codes[i] = cmd.Code626 codes[i] = cmd.Code
619 }627 }
620- 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)628+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
621 }629 }
622 630
623 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {631 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
A internal/usecase/daemon/pane_rearrange.go +34 -0
@@ -0,0 +1,34 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+)
9+
10+var errDaemonActionNoChange = errors.New("daemon action made no change")
11+
12+func (d *Daemon) consumeOrExpelPane(target daemonActionTarget, direction layout.Direction) error {
13+ changed, err := d.mutateTargetLayoutChanged(target, true, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
14+ return candidate.ConsumeOrExpelPane(target.pane.id, direction, area)
15+ })
16+ if err != nil {
17+ return paneRearrangeUserError(err)
18+ }
19+ if !changed {
20+ return errDaemonActionNoChange
21+ }
22+ return nil
23+}
24+
25+func paneRearrangeUserError(err error) error {
26+ switch {
27+ case errors.Is(err, layout.ErrUnsupportedColumnLayout):
28+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane rearrangement requires a column layout", err)
29+ case errors.Is(err, layout.ErrTooSmall):
30+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to rearrange pane", err)
31+ default:
32+ return err
33+ }
34+}
@@ -0,0 +1,34 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+)
9+
10+var errDaemonActionNoChange = errors.New("daemon action made no change")
11+
12+func (d *Daemon) consumeOrExpelPane(target daemonActionTarget, direction layout.Direction) error {
13+ changed, err := d.mutateTargetLayoutChanged(target, true, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
14+ return candidate.ConsumeOrExpelPane(target.pane.id, direction, area)
15+ })
16+ if err != nil {
17+ return paneRearrangeUserError(err)
18+ }
19+ if !changed {
20+ return errDaemonActionNoChange
21+ }
22+ return nil
23+}
24+
25+func paneRearrangeUserError(err error) error {
26+ switch {
27+ case errors.Is(err, layout.ErrUnsupportedColumnLayout):
28+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane rearrangement requires a column layout", err)
29+ case errors.Is(err, layout.ErrTooSmall):
30+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to rearrange pane", err)
31+ default:
32+ return err
33+ }
34+}
A internal/usecase/daemon/pane_rearrange_test.go +605 -0
@@ -0,0 +1,605 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+)
19+
20+func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
21+ tests := []struct {
22+ name string
23+ mutate func(*layout.Tree, domain.Rect) (bool, error)
24+ wantChanged bool
25+ wantGeneration uint64
26+ wantSnapshotGen uint64
27+ wantResizes bool
28+ }{
29+ {
30+ name: "changed candidate publishes once",
31+ mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) {
32+ return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area)
33+ },
34+ wantChanged: true,
35+ wantGeneration: 1,
36+ wantSnapshotGen: 1,
37+ wantResizes: true,
38+ },
39+ {
40+ name: "unchanged candidate is discarded",
41+ mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) {
42+ candidate.Focus = "pane-2"
43+ return false, nil
44+ },
45+ },
46+ }
47+
48+ for _, tt := range tests {
49+ t.Run(tt.name, func(t *testing.T) {
50+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
51+ before := h.tab.tree.Clone()
52+
53+ changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate)
54+
55+ require.NoError(t, err)
56+ require.Equal(t, tt.wantChanged, changed)
57+ h.tab.mu.Lock()
58+ require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration)
59+ if tt.wantChanged {
60+ require.NotEqual(t, before, h.tab.tree)
61+ } else {
62+ require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate")
63+ }
64+ h.tab.mu.Unlock()
65+ h.session.snapshotMu.Lock()
66+ require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration)
67+ h.session.snapshotMu.Unlock()
68+ if tt.wantResizes {
69+ require.NotZero(t, h.totalResizes())
70+ } else {
71+ require.Zero(t, h.totalResizes())
72+ }
73+ })
74+ }
75+}
76+
77+func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ stale func(*paneRearrangeHarness) daemonActionTarget
81+ }{
82+ {
83+ name: "session",
84+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
85+ target := h.target("pane-1")
86+ h.daemon.sessions[h.session.id] = &session{id: h.session.id}
87+ return target
88+ },
89+ },
90+ {
91+ name: "tab",
92+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
93+ target := h.target("pane-1")
94+ h.session.tabs = []*tab{newTab(nil, h.tab.size)}
95+ return target
96+ },
97+ },
98+ {
99+ name: "pane identity",
100+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
101+ target := h.target("pane-1")
102+ h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size)
103+ return target
104+ },
105+ },
106+ }
107+
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
111+ before := h.tab.tree.Clone()
112+ called := false
113+
114+ changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) {
115+ called = true
116+ return true, nil
117+ })
118+
119+ require.False(t, changed)
120+ require.ErrorIs(t, err, layout.ErrNotFound)
121+ require.False(t, called, "stale identity must be rejected before invoking the mutation")
122+ require.Equal(t, before, h.tab.tree)
123+ require.Zero(t, h.tab.layoutGeneration)
124+ require.Zero(t, h.totalResizes())
125+ })
126+ }
127+}
128+
129+func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) {
130+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
131+ locksAvailable := make(chan bool, 8)
132+ for _, tracked := range h.ptys {
133+ tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() }
134+ }
135+
136+ done := make(chan error, 1)
137+ go func() {
138+ done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{
139+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
140+ })
141+ }()
142+
143+ select {
144+ case err := <-done:
145+ require.NoError(t, err)
146+ case <-time.After(2 * time.Second):
147+ t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks")
148+ }
149+ require.NotZero(t, h.totalResizes())
150+ close(locksAvailable)
151+ for available := range locksAvailable {
152+ require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held")
153+ }
154+}
155+
156+func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
157+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
158+ h.tab.mu.Lock()
159+ h.tab.tree.Focus = "pane-3"
160+ before := make(map[layout.PaneID]*pane, len(h.tab.panes))
161+ for id, p := range h.tab.panes {
162+ before[id] = p
163+ }
164+ h.tab.mu.Unlock()
165+
166+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
167+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
168+ })
169+
170+ require.NoError(t, err)
171+ h.tab.mu.Lock()
172+ require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused")
173+ require.Len(t, h.tab.panes, len(before))
174+ for id, p := range before {
175+ require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id)
176+ }
177+ h.tab.mu.Unlock()
178+ h.factory.AssertNotCalled(t, "Open")
179+ for _, tracked := range h.ptys {
180+ require.Zero(t, tracked.closeCount())
181+ }
182+}
183+
184+func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
185+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
186+ ac := &attachedClient{}
187+ ac.setSession(h.session)
188+ h.session.mu.Lock()
189+ h.session.client = ac
190+ h.session.mu.Unlock()
191+ invalidations := make(chan renderInvalidation, 2)
192+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
193+ rc.attach(ac)
194+ h.session.installRenderCoordinator(rc)
195+ before := h.snapshot()
196+ request := daemonActionRequest{
197+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left,
198+ }
199+
200+ err := (daemonActions{d: h.daemon}).Run(request)
201+ require.ErrorIs(t, err, errDaemonActionNoChange, "the daemon action must retain the package no-change sentinel")
202+
203+ require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size")
204+ require.Zero(t, h.totalResizes())
205+ h.factory.AssertNotCalled(t, "Open")
206+ for _, tracked := range h.ptys {
207+ require.Zero(t, tracked.closeCount())
208+ }
209+ require.Empty(t, h.daemon.notices.history())
210+ requireNoInvalidation(t, invalidations)
211+}
212+
213+func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) {
214+ tests := []struct {
215+ name string
216+ size domain.Size
217+ tree *layout.Tree
218+ pane layout.PaneID
219+ dir layout.Direction
220+ cause error
221+ wantMsg string
222+ }{
223+ {
224+ name: "unsupported layout",
225+ size: domain.Size{Cols: 80, Rows: 22},
226+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
227+ layout.NewLeaf("pane-1"),
228+ {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}},
229+ }}, Focus: "pane-1"},
230+ pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout,
231+ wantMsg: "pane rearrangement requires a column layout",
232+ },
233+ {
234+ name: "too small",
235+ size: domain.Size{Cols: 40, Rows: 5},
236+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
237+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
238+ }}, Focus: "pane-1"},
239+ pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall,
240+ wantMsg: "not enough space to rearrange pane",
241+ },
242+ }
243+
244+ for _, tt := range tests {
245+ t.Run(tt.name, func(t *testing.T) {
246+ h := newPaneRearrangeHarness(t, tt.size, tt.tree)
247+ before := h.snapshot()
248+
249+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
250+ kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir,
251+ })
252+
253+ require.ErrorIs(t, err, tt.cause)
254+ var userErr *domain.UserError
255+ require.ErrorAs(t, err, &userErr)
256+ require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code)
257+ require.Equal(t, domain.NoticeWarn, userErr.Severity)
258+ require.Equal(t, tt.wantMsg, userErr.Msg)
259+ require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic")
260+ require.Zero(t, h.totalResizes())
261+ h.factory.AssertNotCalled(t, "Open")
262+ for _, tracked := range h.ptys {
263+ require.Zero(t, tracked.closeCount())
264+ }
265+ })
266+ }
267+}
268+
269+func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
270+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
271+ ac := &attachedClient{}
272+ ac.setSession(h.session)
273+ h.session.mu.Lock()
274+ h.session.client = ac
275+ h.session.mu.Unlock()
276+ invalidations := make(chan renderInvalidation, 2)
277+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
278+ rc.attach(ac)
279+ h.session.installRenderCoordinator(rc)
280+
281+ err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{
282+ kind: daemonActionConsumeOrExpelPane, direction: layout.Right,
283+ })
284+
285+ require.NoError(t, err)
286+ h.tab.mu.Lock()
287+ require.Equal(t, uint64(1), h.tab.layoutGeneration)
288+ h.tab.mu.Unlock()
289+ h.session.snapshotMu.Lock()
290+ require.Equal(t, uint64(1), h.session.snapshotGeneration)
291+ h.session.snapshotMu.Unlock()
292+ require.NotZero(t, h.totalResizes())
293+ awaitInvalidation(t, invalidations)
294+ requireNoInvalidation(t, invalidations)
295+}
296+
297+func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) {
298+ genuine := errors.New("genuine rearrange failure")
299+ directions := []struct {
300+ name string
301+ direction layout.Direction
302+ keyAction keys.Action
303+ paletteRun func(paletteExec) error
304+ controlRun func(controlExec) error
305+ }{
306+ {
307+ name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft,
308+ paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() },
309+ controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() },
310+ },
311+ {
312+ name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight,
313+ paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() },
314+ controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() },
315+ },
316+ }
317+ outcomes := []struct {
318+ name string
319+ err error
320+ wantErr error
321+ wantNotice bool
322+ }{
323+ {name: "silent no-change", err: errDaemonActionNoChange},
324+ {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true},
325+ }
326+
327+ for _, direction := range directions {
328+ t.Run(direction.name, func(t *testing.T) {
329+ for _, outcome := range outcomes {
330+ t.Run(outcome.name, func(t *testing.T) {
331+ d := newTestDaemon(t, nil, stubClock{})
332+ sess := addControlSession(d, "work", "t_work", "p_work")
333+ ac := &attachedClient{}
334+ ac.setSession(sess)
335+ target := resolveDaemonActionTarget(sess)
336+ paletteSpy := &actionRunnerSpy{err: outcome.err}
337+ controlSpy := &actionRunnerSpy{err: outcome.err}
338+ keySpy := &actionRunnerSpy{err: outcome.err}
339+
340+ paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})
341+ controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy})
342+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction)
343+
344+ if outcome.wantErr == nil {
345+ require.NoError(t, paletteErr)
346+ require.NoError(t, controlErr)
347+ } else {
348+ require.ErrorIs(t, paletteErr, outcome.wantErr)
349+ require.ErrorIs(t, controlErr, outcome.wantErr)
350+ }
351+ if outcome.wantNotice {
352+ require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error")
353+ } else {
354+ require.Empty(t, d.notices.history(), "no-change is silent in the key adapter")
355+ }
356+
357+ for adapter, spy := range map[string]*actionRunnerSpy{
358+ "palette": paletteSpy,
359+ "control": controlSpy,
360+ "key": keySpy,
361+ } {
362+ require.Len(t, spy.requests, 1, "%s adapter request count", adapter)
363+ request := spy.requests[0]
364+ require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter)
365+ require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter)
366+ require.Same(t, target.session, request.target.session, "%s adapter target session", adapter)
367+ require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter)
368+ require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter)
369+ }
370+ })
371+ }
372+ })
373+ }
374+}
375+
376+func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
377+ tests := []struct {
378+ name string
379+ invoke func(*paneRearrangeHarness, *attachedClient) error
380+ wantCleanupInvalidation bool
381+ }{
382+ {
383+ name: "palette cleans up its closed overlay",
384+ invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
385+ return (paletteExec{d: h.daemon, sess: h.session, ac: ac, redrawClosedPalette: true}).ConsumeOrExpelPaneLeft()
386+ },
387+ wantCleanupInvalidation: true,
388+ },
389+ {
390+ name: "control remains silent",
391+ invoke: func(h *paneRearrangeHarness, _ *attachedClient) error {
392+ return (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).ConsumeOrExpelPaneLeft()
393+ },
394+ },
395+ {
396+ name: "key remains silent",
397+ invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
398+ daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft)
399+ return nil
400+ },
401+ },
402+ }
403+
404+ for _, tt := range tests {
405+ t.Run(tt.name, func(t *testing.T) {
406+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
407+ ac := &attachedClient{}
408+ ac.setSession(h.session)
409+ h.session.mu.Lock()
410+ h.session.client = ac
411+ h.session.mu.Unlock()
412+ invalidations := make(chan renderInvalidation, 2)
413+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
414+ rc.attach(ac)
415+ h.session.installRenderCoordinator(rc)
416+ before := h.snapshot()
417+
418+ require.NoError(t, tt.invoke(h, ac))
419+
420+ require.Equal(t, before, h.snapshot(), "adapter no-change must not publish layout state")
421+ require.Empty(t, h.daemon.notices.history())
422+ if tt.wantCleanupInvalidation {
423+ invalidation := awaitInvalidation(t, invalidations)
424+ require.Equal(t, "palette.go", invalidation.producer)
425+ require.True(t, invalidation.reset)
426+ }
427+ requireNoInvalidation(t, invalidations)
428+ })
429+ }
430+}
431+
432+type paneRearrangeHarness struct {
433+ daemon *Daemon
434+ session *session
435+ tab *tab
436+ panes map[layout.PaneID]*pane
437+ ptys map[layout.PaneID]*paneRearrangePTY
438+ factory *portsmocks.MockPTYFactory
439+}
440+
441+type paneRearrangeSnapshot struct {
442+ tree *layout.Tree
443+ layoutGeneration uint64
444+ snapshotGeneration uint64
445+ snapshotDirty bool
446+ panes map[layout.PaneID]*pane
447+ rects map[layout.PaneID]domain.Rect
448+ screens map[layout.PaneID]domain.Size
449+}
450+
451+func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness {
452+ t.Helper()
453+ factory := portsmocks.NewMockPTYFactory(t)
454+ d := newTestDaemon(t, factory, stubClock{})
455+ ids := layout.LeafIDs(tree.Root)
456+ require.NotEmpty(t, ids)
457+ ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids))
458+ panes := make(map[layout.PaneID]*pane, len(ids))
459+ for _, id := range ids {
460+ ptys[id] = &paneRearrangePTY{}
461+ }
462+ tb := newTab(ptys[ids[0]], size)
463+ first := tb.panes["pane-1"]
464+ delete(tb.panes, "pane-1")
465+ first.id = ids[0]
466+ panes[ids[0]] = first
467+ tb.panes[ids[0]] = first
468+ for _, id := range ids[1:] {
469+ p := newPane(id, ptys[id], size)
470+ panes[id] = p
471+ tb.panes[id] = p
472+ }
473+ tb.tree = tree.Clone()
474+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
475+ require.True(t, ok, "test fixture layout must solve")
476+ for _, placement := range placements {
477+ p := tb.panes[placement.ID]
478+ p.rect = placement.Content
479+ p.screen.Resize(placement.Content.Width, placement.Content.Height)
480+ }
481+ tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx)
482+ for _, p := range tb.panes {
483+ p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
484+ }
485+ sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
486+ sess.snapEligible.Store(true)
487+ d.sessions[sess.id] = sess
488+ return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
489+}
490+
491+func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) {
492+ t.Helper()
493+ ctx, cancel := context.WithCancel(parent)
494+ t.Cleanup(cancel)
495+ return ctx, cancel
496+}
497+
498+func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget {
499+ return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]}
500+}
501+
502+func (h *paneRearrangeHarness) totalResizes() int {
503+ total := 0
504+ for _, tracked := range h.ptys {
505+ total += tracked.resizeCount()
506+ }
507+ return total
508+}
509+
510+func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
511+ h.tab.mu.Lock()
512+ snapshot := paneRearrangeSnapshot{
513+ tree: h.tab.tree.Clone(),
514+ layoutGeneration: h.tab.layoutGeneration,
515+ panes: make(map[layout.PaneID]*pane, len(h.tab.panes)),
516+ rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)),
517+ screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)),
518+ }
519+ for id, p := range h.tab.panes {
520+ snapshot.panes[id] = p
521+ p.mu.Lock()
522+ snapshot.rects[id] = p.rect
523+ snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}
524+ p.mu.Unlock()
525+ }
526+ h.tab.mu.Unlock()
527+ h.session.snapshotMu.Lock()
528+ snapshot.snapshotGeneration = h.session.snapshotGeneration
529+ snapshot.snapshotDirty = h.session.snapDirty.Load()
530+ h.session.snapshotMu.Unlock()
531+ return snapshot
532+}
533+
534+func (h *paneRearrangeHarness) stateLocksAvailable() bool {
535+ done := make(chan struct{})
536+ go func() {
537+ h.daemon.mu.Lock()
538+ h.session.mu.Lock()
539+ h.tab.mu.Lock()
540+ for _, p := range h.panes {
541+ p.mu.Lock()
542+ }
543+ for _, p := range h.panes {
544+ p.mu.Unlock()
545+ }
546+ h.tab.mu.Unlock()
547+ h.session.mu.Unlock()
548+ h.daemon.mu.Unlock()
549+ close(done)
550+ }()
551+ select {
552+ case <-done:
553+ return true
554+ case <-time.After(250 * time.Millisecond):
555+ return false
556+ }
557+}
558+
559+func threeColumnTree() *layout.Tree {
560+ return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
561+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
562+ }}, Focus: "pane-1"}
563+}
564+
565+type paneRearrangePTY struct {
566+ mu sync.Mutex
567+ resizes []domain.Size
568+ closes int
569+ onResize func()
570+}
571+
572+func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }
573+func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
574+func (p *paneRearrangePTY) Resize(size domain.Size) error {
575+ p.mu.Lock()
576+ p.resizes = append(p.resizes, size)
577+ hook := p.onResize
578+ p.mu.Unlock()
579+ if hook != nil {
580+ hook()
581+ }
582+ return nil
583+}
584+func (p *paneRearrangePTY) Close() error {
585+ p.mu.Lock()
586+ p.closes++
587+ p.mu.Unlock()
588+ return nil
589+}
590+func (*paneRearrangePTY) Pid() int { return 0 }
591+func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil }
592+
593+func (p *paneRearrangePTY) resizeCount() int {
594+ p.mu.Lock()
595+ defer p.mu.Unlock()
596+ return len(p.resizes)
597+}
598+
599+func (p *paneRearrangePTY) closeCount() int {
600+ p.mu.Lock()
601+ defer p.mu.Unlock()
602+ return p.closes
603+}
604+
605+var _ ports.PTY = (*paneRearrangePTY)(nil)
@@ -0,0 +1,605 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+)
19+
20+func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
21+ tests := []struct {
22+ name string
23+ mutate func(*layout.Tree, domain.Rect) (bool, error)
24+ wantChanged bool
25+ wantGeneration uint64
26+ wantSnapshotGen uint64
27+ wantResizes bool
28+ }{
29+ {
30+ name: "changed candidate publishes once",
31+ mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) {
32+ return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area)
33+ },
34+ wantChanged: true,
35+ wantGeneration: 1,
36+ wantSnapshotGen: 1,
37+ wantResizes: true,
38+ },
39+ {
40+ name: "unchanged candidate is discarded",
41+ mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) {
42+ candidate.Focus = "pane-2"
43+ return false, nil
44+ },
45+ },
46+ }
47+
48+ for _, tt := range tests {
49+ t.Run(tt.name, func(t *testing.T) {
50+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
51+ before := h.tab.tree.Clone()
52+
53+ changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate)
54+
55+ require.NoError(t, err)
56+ require.Equal(t, tt.wantChanged, changed)
57+ h.tab.mu.Lock()
58+ require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration)
59+ if tt.wantChanged {
60+ require.NotEqual(t, before, h.tab.tree)
61+ } else {
62+ require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate")
63+ }
64+ h.tab.mu.Unlock()
65+ h.session.snapshotMu.Lock()
66+ require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration)
67+ h.session.snapshotMu.Unlock()
68+ if tt.wantResizes {
69+ require.NotZero(t, h.totalResizes())
70+ } else {
71+ require.Zero(t, h.totalResizes())
72+ }
73+ })
74+ }
75+}
76+
77+func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ stale func(*paneRearrangeHarness) daemonActionTarget
81+ }{
82+ {
83+ name: "session",
84+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
85+ target := h.target("pane-1")
86+ h.daemon.sessions[h.session.id] = &session{id: h.session.id}
87+ return target
88+ },
89+ },
90+ {
91+ name: "tab",
92+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
93+ target := h.target("pane-1")
94+ h.session.tabs = []*tab{newTab(nil, h.tab.size)}
95+ return target
96+ },
97+ },
98+ {
99+ name: "pane identity",
100+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
101+ target := h.target("pane-1")
102+ h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size)
103+ return target
104+ },
105+ },
106+ }
107+
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
111+ before := h.tab.tree.Clone()
112+ called := false
113+
114+ changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) {
115+ called = true
116+ return true, nil
117+ })
118+
119+ require.False(t, changed)
120+ require.ErrorIs(t, err, layout.ErrNotFound)
121+ require.False(t, called, "stale identity must be rejected before invoking the mutation")
122+ require.Equal(t, before, h.tab.tree)
123+ require.Zero(t, h.tab.layoutGeneration)
124+ require.Zero(t, h.totalResizes())
125+ })
126+ }
127+}
128+
129+func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) {
130+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
131+ locksAvailable := make(chan bool, 8)
132+ for _, tracked := range h.ptys {
133+ tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() }
134+ }
135+
136+ done := make(chan error, 1)
137+ go func() {
138+ done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{
139+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
140+ })
141+ }()
142+
143+ select {
144+ case err := <-done:
145+ require.NoError(t, err)
146+ case <-time.After(2 * time.Second):
147+ t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks")
148+ }
149+ require.NotZero(t, h.totalResizes())
150+ close(locksAvailable)
151+ for available := range locksAvailable {
152+ require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held")
153+ }
154+}
155+
156+func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
157+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
158+ h.tab.mu.Lock()
159+ h.tab.tree.Focus = "pane-3"
160+ before := make(map[layout.PaneID]*pane, len(h.tab.panes))
161+ for id, p := range h.tab.panes {
162+ before[id] = p
163+ }
164+ h.tab.mu.Unlock()
165+
166+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
167+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
168+ })
169+
170+ require.NoError(t, err)
171+ h.tab.mu.Lock()
172+ require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused")
173+ require.Len(t, h.tab.panes, len(before))
174+ for id, p := range before {
175+ require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id)
176+ }
177+ h.tab.mu.Unlock()
178+ h.factory.AssertNotCalled(t, "Open")
179+ for _, tracked := range h.ptys {
180+ require.Zero(t, tracked.closeCount())
181+ }
182+}
183+
184+func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
185+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
186+ ac := &attachedClient{}
187+ ac.setSession(h.session)
188+ h.session.mu.Lock()
189+ h.session.client = ac
190+ h.session.mu.Unlock()
191+ invalidations := make(chan renderInvalidation, 2)
192+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
193+ rc.attach(ac)
194+ h.session.installRenderCoordinator(rc)
195+ before := h.snapshot()
196+ request := daemonActionRequest{
197+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left,
198+ }
199+
200+ err := (daemonActions{d: h.daemon}).Run(request)
201+ require.ErrorIs(t, err, errDaemonActionNoChange, "the daemon action must retain the package no-change sentinel")
202+
203+ require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size")
204+ require.Zero(t, h.totalResizes())
205+ h.factory.AssertNotCalled(t, "Open")
206+ for _, tracked := range h.ptys {
207+ require.Zero(t, tracked.closeCount())
208+ }
209+ require.Empty(t, h.daemon.notices.history())
210+ requireNoInvalidation(t, invalidations)
211+}
212+
213+func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) {
214+ tests := []struct {
215+ name string
216+ size domain.Size
217+ tree *layout.Tree
218+ pane layout.PaneID
219+ dir layout.Direction
220+ cause error
221+ wantMsg string
222+ }{
223+ {
224+ name: "unsupported layout",
225+ size: domain.Size{Cols: 80, Rows: 22},
226+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
227+ layout.NewLeaf("pane-1"),
228+ {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}},
229+ }}, Focus: "pane-1"},
230+ pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout,
231+ wantMsg: "pane rearrangement requires a column layout",
232+ },
233+ {
234+ name: "too small",
235+ size: domain.Size{Cols: 40, Rows: 5},
236+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
237+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
238+ }}, Focus: "pane-1"},
239+ pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall,
240+ wantMsg: "not enough space to rearrange pane",
241+ },
242+ }
243+
244+ for _, tt := range tests {
245+ t.Run(tt.name, func(t *testing.T) {
246+ h := newPaneRearrangeHarness(t, tt.size, tt.tree)
247+ before := h.snapshot()
248+
249+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
250+ kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir,
251+ })
252+
253+ require.ErrorIs(t, err, tt.cause)
254+ var userErr *domain.UserError
255+ require.ErrorAs(t, err, &userErr)
256+ require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code)
257+ require.Equal(t, domain.NoticeWarn, userErr.Severity)
258+ require.Equal(t, tt.wantMsg, userErr.Msg)
259+ require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic")
260+ require.Zero(t, h.totalResizes())
261+ h.factory.AssertNotCalled(t, "Open")
262+ for _, tracked := range h.ptys {
263+ require.Zero(t, tracked.closeCount())
264+ }
265+ })
266+ }
267+}
268+
269+func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
270+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
271+ ac := &attachedClient{}
272+ ac.setSession(h.session)
273+ h.session.mu.Lock()
274+ h.session.client = ac
275+ h.session.mu.Unlock()
276+ invalidations := make(chan renderInvalidation, 2)
277+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
278+ rc.attach(ac)
279+ h.session.installRenderCoordinator(rc)
280+
281+ err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{
282+ kind: daemonActionConsumeOrExpelPane, direction: layout.Right,
283+ })
284+
285+ require.NoError(t, err)
286+ h.tab.mu.Lock()
287+ require.Equal(t, uint64(1), h.tab.layoutGeneration)
288+ h.tab.mu.Unlock()
289+ h.session.snapshotMu.Lock()
290+ require.Equal(t, uint64(1), h.session.snapshotGeneration)
291+ h.session.snapshotMu.Unlock()
292+ require.NotZero(t, h.totalResizes())
293+ awaitInvalidation(t, invalidations)
294+ requireNoInvalidation(t, invalidations)
295+}
296+
297+func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) {
298+ genuine := errors.New("genuine rearrange failure")
299+ directions := []struct {
300+ name string
301+ direction layout.Direction
302+ keyAction keys.Action
303+ paletteRun func(paletteExec) error
304+ controlRun func(controlExec) error
305+ }{
306+ {
307+ name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft,
308+ paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() },
309+ controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() },
310+ },
311+ {
312+ name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight,
313+ paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() },
314+ controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() },
315+ },
316+ }
317+ outcomes := []struct {
318+ name string
319+ err error
320+ wantErr error
321+ wantNotice bool
322+ }{
323+ {name: "silent no-change", err: errDaemonActionNoChange},
324+ {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true},
325+ }
326+
327+ for _, direction := range directions {
328+ t.Run(direction.name, func(t *testing.T) {
329+ for _, outcome := range outcomes {
330+ t.Run(outcome.name, func(t *testing.T) {
331+ d := newTestDaemon(t, nil, stubClock{})
332+ sess := addControlSession(d, "work", "t_work", "p_work")
333+ ac := &attachedClient{}
334+ ac.setSession(sess)
335+ target := resolveDaemonActionTarget(sess)
336+ paletteSpy := &actionRunnerSpy{err: outcome.err}
337+ controlSpy := &actionRunnerSpy{err: outcome.err}
338+ keySpy := &actionRunnerSpy{err: outcome.err}
339+
340+ paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})
341+ controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy})
342+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction)
343+
344+ if outcome.wantErr == nil {
345+ require.NoError(t, paletteErr)
346+ require.NoError(t, controlErr)
347+ } else {
348+ require.ErrorIs(t, paletteErr, outcome.wantErr)
349+ require.ErrorIs(t, controlErr, outcome.wantErr)
350+ }
351+ if outcome.wantNotice {
352+ require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error")
353+ } else {
354+ require.Empty(t, d.notices.history(), "no-change is silent in the key adapter")
355+ }
356+
357+ for adapter, spy := range map[string]*actionRunnerSpy{
358+ "palette": paletteSpy,
359+ "control": controlSpy,
360+ "key": keySpy,
361+ } {
362+ require.Len(t, spy.requests, 1, "%s adapter request count", adapter)
363+ request := spy.requests[0]
364+ require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter)
365+ require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter)
366+ require.Same(t, target.session, request.target.session, "%s adapter target session", adapter)
367+ require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter)
368+ require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter)
369+ }
370+ })
371+ }
372+ })
373+ }
374+}
375+
376+func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
377+ tests := []struct {
378+ name string
379+ invoke func(*paneRearrangeHarness, *attachedClient) error
380+ wantCleanupInvalidation bool
381+ }{
382+ {
383+ name: "palette cleans up its closed overlay",
384+ invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
385+ return (paletteExec{d: h.daemon, sess: h.session, ac: ac, redrawClosedPalette: true}).ConsumeOrExpelPaneLeft()
386+ },
387+ wantCleanupInvalidation: true,
388+ },
389+ {
390+ name: "control remains silent",
391+ invoke: func(h *paneRearrangeHarness, _ *attachedClient) error {
392+ return (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).ConsumeOrExpelPaneLeft()
393+ },
394+ },
395+ {
396+ name: "key remains silent",
397+ invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
398+ daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft)
399+ return nil
400+ },
401+ },
402+ }
403+
404+ for _, tt := range tests {
405+ t.Run(tt.name, func(t *testing.T) {
406+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
407+ ac := &attachedClient{}
408+ ac.setSession(h.session)
409+ h.session.mu.Lock()
410+ h.session.client = ac
411+ h.session.mu.Unlock()
412+ invalidations := make(chan renderInvalidation, 2)
413+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
414+ rc.attach(ac)
415+ h.session.installRenderCoordinator(rc)
416+ before := h.snapshot()
417+
418+ require.NoError(t, tt.invoke(h, ac))
419+
420+ require.Equal(t, before, h.snapshot(), "adapter no-change must not publish layout state")
421+ require.Empty(t, h.daemon.notices.history())
422+ if tt.wantCleanupInvalidation {
423+ invalidation := awaitInvalidation(t, invalidations)
424+ require.Equal(t, "palette.go", invalidation.producer)
425+ require.True(t, invalidation.reset)
426+ }
427+ requireNoInvalidation(t, invalidations)
428+ })
429+ }
430+}
431+
432+type paneRearrangeHarness struct {
433+ daemon *Daemon
434+ session *session
435+ tab *tab
436+ panes map[layout.PaneID]*pane
437+ ptys map[layout.PaneID]*paneRearrangePTY
438+ factory *portsmocks.MockPTYFactory
439+}
440+
441+type paneRearrangeSnapshot struct {
442+ tree *layout.Tree
443+ layoutGeneration uint64
444+ snapshotGeneration uint64
445+ snapshotDirty bool
446+ panes map[layout.PaneID]*pane
447+ rects map[layout.PaneID]domain.Rect
448+ screens map[layout.PaneID]domain.Size
449+}
450+
451+func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness {
452+ t.Helper()
453+ factory := portsmocks.NewMockPTYFactory(t)
454+ d := newTestDaemon(t, factory, stubClock{})
455+ ids := layout.LeafIDs(tree.Root)
456+ require.NotEmpty(t, ids)
457+ ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids))
458+ panes := make(map[layout.PaneID]*pane, len(ids))
459+ for _, id := range ids {
460+ ptys[id] = &paneRearrangePTY{}
461+ }
462+ tb := newTab(ptys[ids[0]], size)
463+ first := tb.panes["pane-1"]
464+ delete(tb.panes, "pane-1")
465+ first.id = ids[0]
466+ panes[ids[0]] = first
467+ tb.panes[ids[0]] = first
468+ for _, id := range ids[1:] {
469+ p := newPane(id, ptys[id], size)
470+ panes[id] = p
471+ tb.panes[id] = p
472+ }
473+ tb.tree = tree.Clone()
474+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
475+ require.True(t, ok, "test fixture layout must solve")
476+ for _, placement := range placements {
477+ p := tb.panes[placement.ID]
478+ p.rect = placement.Content
479+ p.screen.Resize(placement.Content.Width, placement.Content.Height)
480+ }
481+ tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx)
482+ for _, p := range tb.panes {
483+ p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
484+ }
485+ sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
486+ sess.snapEligible.Store(true)
487+ d.sessions[sess.id] = sess
488+ return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
489+}
490+
491+func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) {
492+ t.Helper()
493+ ctx, cancel := context.WithCancel(parent)
494+ t.Cleanup(cancel)
495+ return ctx, cancel
496+}
497+
498+func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget {
499+ return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]}
500+}
501+
502+func (h *paneRearrangeHarness) totalResizes() int {
503+ total := 0
504+ for _, tracked := range h.ptys {
505+ total += tracked.resizeCount()
506+ }
507+ return total
508+}
509+
510+func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
511+ h.tab.mu.Lock()
512+ snapshot := paneRearrangeSnapshot{
513+ tree: h.tab.tree.Clone(),
514+ layoutGeneration: h.tab.layoutGeneration,
515+ panes: make(map[layout.PaneID]*pane, len(h.tab.panes)),
516+ rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)),
517+ screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)),
518+ }
519+ for id, p := range h.tab.panes {
520+ snapshot.panes[id] = p
521+ p.mu.Lock()
522+ snapshot.rects[id] = p.rect
523+ snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}
524+ p.mu.Unlock()
525+ }
526+ h.tab.mu.Unlock()
527+ h.session.snapshotMu.Lock()
528+ snapshot.snapshotGeneration = h.session.snapshotGeneration
529+ snapshot.snapshotDirty = h.session.snapDirty.Load()
530+ h.session.snapshotMu.Unlock()
531+ return snapshot
532+}
533+
534+func (h *paneRearrangeHarness) stateLocksAvailable() bool {
535+ done := make(chan struct{})
536+ go func() {
537+ h.daemon.mu.Lock()
538+ h.session.mu.Lock()
539+ h.tab.mu.Lock()
540+ for _, p := range h.panes {
541+ p.mu.Lock()
542+ }
543+ for _, p := range h.panes {
544+ p.mu.Unlock()
545+ }
546+ h.tab.mu.Unlock()
547+ h.session.mu.Unlock()
548+ h.daemon.mu.Unlock()
549+ close(done)
550+ }()
551+ select {
552+ case <-done:
553+ return true
554+ case <-time.After(250 * time.Millisecond):
555+ return false
556+ }
557+}
558+
559+func threeColumnTree() *layout.Tree {
560+ return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
561+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
562+ }}, Focus: "pane-1"}
563+}
564+
565+type paneRearrangePTY struct {
566+ mu sync.Mutex
567+ resizes []domain.Size
568+ closes int
569+ onResize func()
570+}
571+
572+func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }
573+func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
574+func (p *paneRearrangePTY) Resize(size domain.Size) error {
575+ p.mu.Lock()
576+ p.resizes = append(p.resizes, size)
577+ hook := p.onResize
578+ p.mu.Unlock()
579+ if hook != nil {
580+ hook()
581+ }
582+ return nil
583+}
584+func (p *paneRearrangePTY) Close() error {
585+ p.mu.Lock()
586+ p.closes++
587+ p.mu.Unlock()
588+ return nil
589+}
590+func (*paneRearrangePTY) Pid() int { return 0 }
591+func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil }
592+
593+func (p *paneRearrangePTY) resizeCount() int {
594+ p.mu.Lock()
595+ defer p.mu.Unlock()
596+ return len(p.resizes)
597+}
598+
599+func (p *paneRearrangePTY) closeCount() int {
600+ p.mu.Lock()
601+ defer p.mu.Unlock()
602+ return p.closes
603+}
604+
605+var _ ports.PTY = (*paneRearrangePTY)(nil)
M internal/usecase/daemon/performance_benchmark_test.go +4 -6
@@ -64,9 +64,9 @@ func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *
6464 func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {
6565 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
6666
67- markSnapshotDirty(fixture.sess)
68- require.True(t, fixture.d.scheduleSnapshot(fixture.sess))
69- awaitSnapshotClean(t, fixture.sess)
67+ // This contract concerns capture and publication bytes, not queue latency.
68+ // Publish synchronously so the 10k-row setup cannot race the test deadline.
69+ publishBenchmarkSnapshot(t, fixture, 1)
7070
7171 var copies atomic.Uint64
7272 fixture.sess.snapshotMu.Lock()
@@ -83,9 +83,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
8383 for i, r := range "changed" {
8484 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
8585 }
86- markSnapshotDirty(fixture.sess)
87- require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess))
88- awaitSnapshotClean(t, fixture.sess)
86+ publishBenchmarkSnapshot(t, fixture, 2)
8987
9088 metrics := fixture.snapshots.metrics()
9189 require.Equal(t, uint64(1), metrics.writes)
@@ -64,9 +64,9 @@ func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *
64 func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {64 func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {
65 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})65 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
66 66
67- markSnapshotDirty(fixture.sess)67+ // This contract concerns capture and publication bytes, not queue latency.
68- require.True(t, fixture.d.scheduleSnapshot(fixture.sess))68+ // Publish synchronously so the 10k-row setup cannot race the test deadline.
69- awaitSnapshotClean(t, fixture.sess)69+ publishBenchmarkSnapshot(t, fixture, 1)
70 70
71 var copies atomic.Uint6471 var copies atomic.Uint64
72 fixture.sess.snapshotMu.Lock()72 fixture.sess.snapshotMu.Lock()
@@ -83,9 +83,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
83 for i, r := range "changed" {83 for i, r := range "changed" {
84 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)84 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
85 }85 }
86- markSnapshotDirty(fixture.sess)86+ publishBenchmarkSnapshot(t, fixture, 2)
87- require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess))
88- awaitSnapshotClean(t, fixture.sess)
89 87
90 metrics := fixture.snapshots.metrics()88 metrics := fixture.snapshots.metrics()
91 require.Equal(t, uint64(1), metrics.writes)89 require.Equal(t, uint64(1), metrics.writes)
M internal/usecase/daemon/resize_mode.go +0 -48
@@ -32,54 +32,6 @@ func (d *Daemon) equalizePanes(target daemonActionTarget) error {
3232 })
3333 }
3434
35-func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
36- if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
37- return layout.ErrNotFound
38- }
39- d.mu.Lock()
40- if d.sessions[target.session.id] != target.session {
41- d.mu.Unlock()
42- return layout.ErrNotFound
43- }
44- target.session.mu.Lock()
45- foundTab := false
46- for _, tb := range target.session.tabs {
47- if tb == target.tab {
48- foundTab = true
49- break
50- }
51- }
52- if !foundTab {
53- target.session.mu.Unlock()
54- d.mu.Unlock()
55- return layout.ErrNotFound
56- }
57- target.tab.mu.Lock()
58- if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
59- target.tab.mu.Unlock()
60- target.session.mu.Unlock()
61- d.mu.Unlock()
62- return layout.ErrNotFound
63- }
64- candidate := target.tab.tree.Clone()
65- area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
66- if err := mutate(candidate, area); err != nil {
67- target.tab.mu.Unlock()
68- target.session.mu.Unlock()
69- d.mu.Unlock()
70- return err
71- }
72- target.tab.tree = candidate
73- target.tab.bumpLayoutGenerationLocked()
74- target.tab.mu.Unlock()
75- target.session.mu.Unlock()
76- d.mu.Unlock()
77- if !d.applyTabLayout(target.session, target.tab) {
78- return layout.ErrNotFound
79- }
80- return nil
81-}
82-
8335 func resizeUserError(err error) error {
8436 switch {
8537 case errors.Is(err, layout.ErrNotInSplit):
@@ -32,54 +32,6 @@ func (d *Daemon) equalizePanes(target daemonActionTarget) error {
32 })32 })
33 }33 }
34 34
35-func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
36- if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
37- return layout.ErrNotFound
38- }
39- d.mu.Lock()
40- if d.sessions[target.session.id] != target.session {
41- d.mu.Unlock()
42- return layout.ErrNotFound
43- }
44- target.session.mu.Lock()
45- foundTab := false
46- for _, tb := range target.session.tabs {
47- if tb == target.tab {
48- foundTab = true
49- break
50- }
51- }
52- if !foundTab {
53- target.session.mu.Unlock()
54- d.mu.Unlock()
55- return layout.ErrNotFound
56- }
57- target.tab.mu.Lock()
58- if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
59- target.tab.mu.Unlock()
60- target.session.mu.Unlock()
61- d.mu.Unlock()
62- return layout.ErrNotFound
63- }
64- candidate := target.tab.tree.Clone()
65- area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
66- if err := mutate(candidate, area); err != nil {
67- target.tab.mu.Unlock()
68- target.session.mu.Unlock()
69- d.mu.Unlock()
70- return err
71- }
72- target.tab.tree = candidate
73- target.tab.bumpLayoutGenerationLocked()
74- target.tab.mu.Unlock()
75- target.session.mu.Unlock()
76- d.mu.Unlock()
77- if !d.applyTabLayout(target.session, target.tab) {
78- return layout.ErrNotFound
79- }
80- return nil
81-}
82-
83 func resizeUserError(err error) error {35 func resizeUserError(err error) error {
84 switch {36 switch {
85 case errors.Is(err, layout.ErrNotInSplit):37 case errors.Is(err, layout.ErrNotInSplit):
M internal/usecase/daemon/snapshot_restore_test.go +154 -10
@@ -41,11 +41,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap
4141 if r.publishErr != nil {
4242 return r.publishErr
4343 }
44- objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects))
45- for _, object := range p.Objects {
46- objects[object.Digest] = append([]byte(nil), object.Data...)
47- }
48- r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
44+ r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p))
4945 found := false
5046 for _, name := range r.names {
5147 found = found || name == p.Name
@@ -183,11 +179,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
183179 manifest.Generation = generation
184180 encoded, err := snapcodec.MarshalManifest(manifest)
185181 require.NoError(t, err)
186- objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
187- for _, object := range publication.Objects {
188- objects[object.Digest] = append([]byte(nil), object.Data...)
189- }
190- return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects}
182+ publication.Generation = generation
183+ publication.Manifest = encoded
184+ return cloneAcceptanceGeneration(snapshotGeneration(publication))
191185 }
192186
193187 func TestValidateRestoreSessionSnapshot(t *testing.T) {
@@ -267,6 +261,156 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
267261 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
268262 }
269263
264+type paneLayoutSnapshotFixture struct {
265+ snapshot snapcodec.Session
266+ tree *layout.Tree
267+ tabStableID string
268+ paneStableIDs map[layout.PaneID]string
269+}
270+
271+type restoredPaneLayout struct {
272+ tree *layout.Tree
273+ tabStableID string
274+ paneStableIDs map[layout.PaneID]string
275+}
276+
277+func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) {
278+ fixture := captureConsumedAndExpelledPaneLayout(t)
279+ require.Len(t, fixture.snapshot.Tabs, 1)
280+ decodedTab := fixture.snapshot.Tabs[0]
281+
282+ require.Equal(t, fixture.tabStableID, decodedTab.StableID)
283+ require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved")
284+ assertConsumedAndExpelledColumns(t, decodedTab.Tree)
285+ require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree")
286+ require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID")
287+}
288+
289+func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) {
290+ fixture := captureConsumedAndExpelledPaneLayout(t)
291+ restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{})
292+ restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot)
293+ require.NoError(t, err)
294+ defer closeRestoredTabs(restoredTabs)
295+ require.Len(t, restoredTabs, 1)
296+
297+ restored := captureRestoredPaneLayout(restoredTabs[0])
298+ require.Equal(t, fixture.tabStableID, restored.tabStableID)
299+ assertConsumedAndExpelledColumns(t, restored.tree)
300+ require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree")
301+ require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID")
302+}
303+
304+func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture {
305+ t.Helper()
306+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree())
307+ h.session.createdAt = 77
308+ h.session.incarnation = domain.IncarnationID{7}
309+ tabStableID := "tab-stable-rearranged"
310+ paneStableIDs := map[layout.PaneID]string{
311+ "pane-1": "pane-stable-1",
312+ "pane-2": "pane-stable-2",
313+ "pane-3": "pane-stable-3",
314+ "pane-4": "pane-stable-4",
315+ "pane-5": "pane-stable-5",
316+ }
317+ setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs)
318+ applyPaneLayoutSnapshotOperations(t, h)
319+
320+ h.tab.mu.Lock()
321+ wantTree := h.tab.tree.Clone()
322+ h.tab.mu.Unlock()
323+ capture, ok := h.daemon.captureSnapshotState(h.session, 7)
324+ require.True(t, ok)
325+ publication, err := h.daemon.incrementalPublication(capture)
326+ require.NoError(t, err)
327+ decoded, err := sessionFromGeneration(snapshotGeneration(publication))
328+ require.NoError(t, err)
329+
330+ return paneLayoutSnapshotFixture{
331+ snapshot: decoded,
332+ tree: wantTree,
333+ tabStableID: tabStableID,
334+ paneStableIDs: paneStableIDs,
335+ }
336+}
337+
338+func paneLayoutSnapshotTree() *layout.Tree {
339+ return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{
340+ {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40},
341+ {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{
342+ {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10},
343+ {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20},
344+ }},
345+ {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{
346+ {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7},
347+ {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3},
348+ }},
349+ }}, Focus: "pane-4"}
350+}
351+
352+func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) {
353+ tb.mu.Lock()
354+ defer tb.mu.Unlock()
355+ tb.stableID = tabStableID
356+ tb.nextPaneID = 6
357+ for id, stableID := range paneStableIDs {
358+ tb.panes[id].mu.Lock()
359+ tb.panes[id].stableID = stableID
360+ tb.panes[id].mu.Unlock()
361+ }
362+}
363+
364+func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) {
365+ t.Helper()
366+ for _, operation := range []struct {
367+ name string
368+ pane layout.PaneID
369+ direction layout.Direction
370+ }{
371+ {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right},
372+ {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right},
373+ } {
374+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
375+ kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction,
376+ })
377+ require.NoError(t, err, operation.name)
378+ }
379+}
380+
381+func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) {
382+ t.Helper()
383+ require.NotNil(t, tree)
384+ require.NotNil(t, tree.Root)
385+ require.Equal(t, layout.Horizontal, tree.Root.Dir)
386+ require.Len(t, tree.Root.Children, 3)
387+ require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column")
388+ require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column")
389+ require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present")
390+ require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved")
391+ require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved")
392+}
393+
394+func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string {
395+ stableIDs := make(map[layout.PaneID]string, len(panes))
396+ for _, p := range panes {
397+ stableIDs[p.ID] = p.StableID
398+ }
399+ return stableIDs
400+}
401+
402+func captureRestoredPaneLayout(tb *tab) restoredPaneLayout {
403+ tb.mu.Lock()
404+ defer tb.mu.Unlock()
405+ stableIDs := make(map[layout.PaneID]string, len(tb.panes))
406+ for id, p := range tb.panes {
407+ p.mu.Lock()
408+ stableIDs[id] = p.stableID
409+ p.mu.Unlock()
410+ }
411+ return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs}
412+}
413+
270414 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
271415 snapshot := restoreAcceptanceSession(t, "restored")
272416 generation := acceptanceGeneration(t, snapshot, 9)
@@ -41,11 +41,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap
41 if r.publishErr != nil {41 if r.publishErr != nil {
42 return r.publishErr42 return r.publishErr
43 }43 }
44- objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects))44+ r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p))
45- for _, object := range p.Objects {
46- objects[object.Digest] = append([]byte(nil), object.Data...)
47- }
48- r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
49 found := false45 found := false
50 for _, name := range r.names {46 for _, name := range r.names {
51 found = found || name == p.Name47 found = found || name == p.Name
@@ -183,11 +179,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
183 manifest.Generation = generation179 manifest.Generation = generation
184 encoded, err := snapcodec.MarshalManifest(manifest)180 encoded, err := snapcodec.MarshalManifest(manifest)
185 require.NoError(t, err)181 require.NoError(t, err)
186- objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))182+ publication.Generation = generation
187- for _, object := range publication.Objects {183+ publication.Manifest = encoded
188- objects[object.Digest] = append([]byte(nil), object.Data...)184+ return cloneAcceptanceGeneration(snapshotGeneration(publication))
189- }
190- return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects}
191 }185 }
192 186
193 func TestValidateRestoreSessionSnapshot(t *testing.T) {187 func TestValidateRestoreSessionSnapshot(t *testing.T) {
@@ -267,6 +261,156 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
267 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")261 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
268 }262 }
269 263
264+type paneLayoutSnapshotFixture struct {
265+ snapshot snapcodec.Session
266+ tree *layout.Tree
267+ tabStableID string
268+ paneStableIDs map[layout.PaneID]string
269+}
270+
271+type restoredPaneLayout struct {
272+ tree *layout.Tree
273+ tabStableID string
274+ paneStableIDs map[layout.PaneID]string
275+}
276+
277+func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) {
278+ fixture := captureConsumedAndExpelledPaneLayout(t)
279+ require.Len(t, fixture.snapshot.Tabs, 1)
280+ decodedTab := fixture.snapshot.Tabs[0]
281+
282+ require.Equal(t, fixture.tabStableID, decodedTab.StableID)
283+ require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved")
284+ assertConsumedAndExpelledColumns(t, decodedTab.Tree)
285+ require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree")
286+ require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID")
287+}
288+
289+func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) {
290+ fixture := captureConsumedAndExpelledPaneLayout(t)
291+ restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{})
292+ restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot)
293+ require.NoError(t, err)
294+ defer closeRestoredTabs(restoredTabs)
295+ require.Len(t, restoredTabs, 1)
296+
297+ restored := captureRestoredPaneLayout(restoredTabs[0])
298+ require.Equal(t, fixture.tabStableID, restored.tabStableID)
299+ assertConsumedAndExpelledColumns(t, restored.tree)
300+ require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree")
301+ require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID")
302+}
303+
304+func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture {
305+ t.Helper()
306+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree())
307+ h.session.createdAt = 77
308+ h.session.incarnation = domain.IncarnationID{7}
309+ tabStableID := "tab-stable-rearranged"
310+ paneStableIDs := map[layout.PaneID]string{
311+ "pane-1": "pane-stable-1",
312+ "pane-2": "pane-stable-2",
313+ "pane-3": "pane-stable-3",
314+ "pane-4": "pane-stable-4",
315+ "pane-5": "pane-stable-5",
316+ }
317+ setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs)
318+ applyPaneLayoutSnapshotOperations(t, h)
319+
320+ h.tab.mu.Lock()
321+ wantTree := h.tab.tree.Clone()
322+ h.tab.mu.Unlock()
323+ capture, ok := h.daemon.captureSnapshotState(h.session, 7)
324+ require.True(t, ok)
325+ publication, err := h.daemon.incrementalPublication(capture)
326+ require.NoError(t, err)
327+ decoded, err := sessionFromGeneration(snapshotGeneration(publication))
328+ require.NoError(t, err)
329+
330+ return paneLayoutSnapshotFixture{
331+ snapshot: decoded,
332+ tree: wantTree,
333+ tabStableID: tabStableID,
334+ paneStableIDs: paneStableIDs,
335+ }
336+}
337+
338+func paneLayoutSnapshotTree() *layout.Tree {
339+ return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{
340+ {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40},
341+ {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{
342+ {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10},
343+ {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20},
344+ }},
345+ {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{
346+ {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7},
347+ {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3},
348+ }},
349+ }}, Focus: "pane-4"}
350+}
351+
352+func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) {
353+ tb.mu.Lock()
354+ defer tb.mu.Unlock()
355+ tb.stableID = tabStableID
356+ tb.nextPaneID = 6
357+ for id, stableID := range paneStableIDs {
358+ tb.panes[id].mu.Lock()
359+ tb.panes[id].stableID = stableID
360+ tb.panes[id].mu.Unlock()
361+ }
362+}
363+
364+func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) {
365+ t.Helper()
366+ for _, operation := range []struct {
367+ name string
368+ pane layout.PaneID
369+ direction layout.Direction
370+ }{
371+ {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right},
372+ {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right},
373+ } {
374+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
375+ kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction,
376+ })
377+ require.NoError(t, err, operation.name)
378+ }
379+}
380+
381+func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) {
382+ t.Helper()
383+ require.NotNil(t, tree)
384+ require.NotNil(t, tree.Root)
385+ require.Equal(t, layout.Horizontal, tree.Root.Dir)
386+ require.Len(t, tree.Root.Children, 3)
387+ require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column")
388+ require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column")
389+ require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present")
390+ require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved")
391+ require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved")
392+}
393+
394+func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string {
395+ stableIDs := make(map[layout.PaneID]string, len(panes))
396+ for _, p := range panes {
397+ stableIDs[p.ID] = p.StableID
398+ }
399+ return stableIDs
400+}
401+
402+func captureRestoredPaneLayout(tb *tab) restoredPaneLayout {
403+ tb.mu.Lock()
404+ defer tb.mu.Unlock()
405+ stableIDs := make(map[layout.PaneID]string, len(tb.panes))
406+ for id, p := range tb.panes {
407+ p.mu.Lock()
408+ stableIDs[id] = p.stableID
409+ p.mu.Unlock()
410+ }
411+ return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs}
412+}
413+
270 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {414 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
271 snapshot := restoreAcceptanceSession(t, "restored")415 snapshot := restoreAcceptanceSession(t, "restored")
272 generation := acceptanceGeneration(t, snapshot, 9)416 generation := acceptanceGeneration(t, snapshot, 9)
A internal/usecase/keys/bindings_test.go +51 -0
@@ -0,0 +1,51 @@
1+package keys
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestConsumeOrExpelPaneActionsBindWithoutDefaults(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ action Action
13+ key rune
14+ }{
15+ {name: "consume-or-expel-pane-left", action: ActionConsumeOrExpelPaneLeft, key: 'H'},
16+ {name: "consume-or-expel-pane-right", action: ActionConsumeOrExpelPaneRight, key: 'L'},
17+ }
18+
19+ for _, tt := range tests {
20+ t.Run(tt.name, func(t *testing.T) {
21+ bindings, warnings := BuildBindings(map[string]string{
22+ tt.name: "alt+" + string(tt.key),
23+ })
24+
25+ require.Empty(t, warnings)
26+ assertAltRuneAction(t, bindings, tt.key, tt.action)
27+ })
28+ }
29+}
30+
31+func TestConsumeOrExpelPaneActionsAreAbsentFromAllDefaultBindings(t *testing.T) {
32+ tests := []struct {
33+ name string
34+ action Action
35+ }{
36+ {name: "left", action: ActionConsumeOrExpelPaneLeft},
37+ {name: "right", action: ActionConsumeOrExpelPaneRight},
38+ }
39+ defaults := DefaultBindings()
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ for _, bound := range defaults.altRunes {
44+ require.NotEqual(t, tt.action, bound)
45+ }
46+ for _, bound := range defaults.altArrows {
47+ require.NotEqual(t, tt.action, bound)
48+ }
49+ })
50+ }
51+}
@@ -0,0 +1,51 @@
1+package keys
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestConsumeOrExpelPaneActionsBindWithoutDefaults(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ action Action
13+ key rune
14+ }{
15+ {name: "consume-or-expel-pane-left", action: ActionConsumeOrExpelPaneLeft, key: 'H'},
16+ {name: "consume-or-expel-pane-right", action: ActionConsumeOrExpelPaneRight, key: 'L'},
17+ }
18+
19+ for _, tt := range tests {
20+ t.Run(tt.name, func(t *testing.T) {
21+ bindings, warnings := BuildBindings(map[string]string{
22+ tt.name: "alt+" + string(tt.key),
23+ })
24+
25+ require.Empty(t, warnings)
26+ assertAltRuneAction(t, bindings, tt.key, tt.action)
27+ })
28+ }
29+}
30+
31+func TestConsumeOrExpelPaneActionsAreAbsentFromAllDefaultBindings(t *testing.T) {
32+ tests := []struct {
33+ name string
34+ action Action
35+ }{
36+ {name: "left", action: ActionConsumeOrExpelPaneLeft},
37+ {name: "right", action: ActionConsumeOrExpelPaneRight},
38+ }
39+ defaults := DefaultBindings()
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ for _, bound := range defaults.altRunes {
44+ require.NotEqual(t, tt.action, bound)
45+ }
46+ for _, bound := range defaults.altArrows {
47+ require.NotEqual(t, tt.action, bound)
48+ }
49+ })
50+ }
51+}
M internal/usecase/keys/keyspec.go +23 -21
@@ -49,27 +49,29 @@ func ParseKeySpec(spec string) (KeySpec, error) {
4949 }
5050
5151 var actionNames = map[Action]string{
52- ActionOpenPalette: "open-palette",
53- ActionToggleFloatingPane: "toggle-floating-pane",
54- ActionJumpAttention: "jump-attention",
55- ActionFocusPaneLeft: "focus-pane-left",
56- ActionFocusPaneRight: "focus-pane-right",
57- ActionFocusPaneUp: "focus-pane-up",
58- ActionFocusPaneDown: "focus-pane-down",
59- ActionSwitchTab1: "switch-tab-1",
60- ActionSwitchTab2: "switch-tab-2",
61- ActionSwitchTab3: "switch-tab-3",
62- ActionSwitchTab4: "switch-tab-4",
63- ActionSwitchTab5: "switch-tab-5",
64- ActionSwitchTab6: "switch-tab-6",
65- ActionSwitchTab7: "switch-tab-7",
66- ActionSwitchTab8: "switch-tab-8",
67- ActionSwitchTab9: "switch-tab-9",
68- ActionGrowPaneWidth: "grow-pane-width",
69- ActionShrinkPaneWidth: "shrink-pane-width",
70- ActionGrowPaneHeight: "grow-pane-height",
71- ActionShrinkPaneHeight: "shrink-pane-height",
72- ActionEqualizePanes: "equalize-panes",
52+ ActionOpenPalette: "open-palette",
53+ ActionToggleFloatingPane: "toggle-floating-pane",
54+ ActionJumpAttention: "jump-attention",
55+ ActionFocusPaneLeft: "focus-pane-left",
56+ ActionFocusPaneRight: "focus-pane-right",
57+ ActionFocusPaneUp: "focus-pane-up",
58+ ActionFocusPaneDown: "focus-pane-down",
59+ ActionSwitchTab1: "switch-tab-1",
60+ ActionSwitchTab2: "switch-tab-2",
61+ ActionSwitchTab3: "switch-tab-3",
62+ ActionSwitchTab4: "switch-tab-4",
63+ ActionSwitchTab5: "switch-tab-5",
64+ ActionSwitchTab6: "switch-tab-6",
65+ ActionSwitchTab7: "switch-tab-7",
66+ ActionSwitchTab8: "switch-tab-8",
67+ ActionSwitchTab9: "switch-tab-9",
68+ ActionGrowPaneWidth: "grow-pane-width",
69+ ActionShrinkPaneWidth: "shrink-pane-width",
70+ ActionGrowPaneHeight: "grow-pane-height",
71+ ActionShrinkPaneHeight: "shrink-pane-height",
72+ ActionEqualizePanes: "equalize-panes",
73+ ActionConsumeOrExpelPaneLeft: "consume-or-expel-pane-left",
74+ ActionConsumeOrExpelPaneRight: "consume-or-expel-pane-right",
7375 }
7476
7577 var actionsByName = func() map[string]Action {
@@ -49,27 +49,29 @@ func ParseKeySpec(spec string) (KeySpec, error) {
49 }49 }
50 50
51 var actionNames = map[Action]string{51 var actionNames = map[Action]string{
52- ActionOpenPalette: "open-palette",52+ ActionOpenPalette: "open-palette",
53- ActionToggleFloatingPane: "toggle-floating-pane",53+ ActionToggleFloatingPane: "toggle-floating-pane",
54- ActionJumpAttention: "jump-attention",54+ ActionJumpAttention: "jump-attention",
55- ActionFocusPaneLeft: "focus-pane-left",55+ ActionFocusPaneLeft: "focus-pane-left",
56- ActionFocusPaneRight: "focus-pane-right",56+ ActionFocusPaneRight: "focus-pane-right",
57- ActionFocusPaneUp: "focus-pane-up",57+ ActionFocusPaneUp: "focus-pane-up",
58- ActionFocusPaneDown: "focus-pane-down",58+ ActionFocusPaneDown: "focus-pane-down",
59- ActionSwitchTab1: "switch-tab-1",59+ ActionSwitchTab1: "switch-tab-1",
60- ActionSwitchTab2: "switch-tab-2",60+ ActionSwitchTab2: "switch-tab-2",
61- ActionSwitchTab3: "switch-tab-3",61+ ActionSwitchTab3: "switch-tab-3",
62- ActionSwitchTab4: "switch-tab-4",62+ ActionSwitchTab4: "switch-tab-4",
63- ActionSwitchTab5: "switch-tab-5",63+ ActionSwitchTab5: "switch-tab-5",
64- ActionSwitchTab6: "switch-tab-6",64+ ActionSwitchTab6: "switch-tab-6",
65- ActionSwitchTab7: "switch-tab-7",65+ ActionSwitchTab7: "switch-tab-7",
66- ActionSwitchTab8: "switch-tab-8",66+ ActionSwitchTab8: "switch-tab-8",
67- ActionSwitchTab9: "switch-tab-9",67+ ActionSwitchTab9: "switch-tab-9",
68- ActionGrowPaneWidth: "grow-pane-width",68+ ActionGrowPaneWidth: "grow-pane-width",
69- ActionShrinkPaneWidth: "shrink-pane-width",69+ ActionShrinkPaneWidth: "shrink-pane-width",
70- ActionGrowPaneHeight: "grow-pane-height",70+ ActionGrowPaneHeight: "grow-pane-height",
71- ActionShrinkPaneHeight: "shrink-pane-height",71+ ActionShrinkPaneHeight: "shrink-pane-height",
72- ActionEqualizePanes: "equalize-panes",72+ ActionEqualizePanes: "equalize-panes",
73+ ActionConsumeOrExpelPaneLeft: "consume-or-expel-pane-left",
74+ ActionConsumeOrExpelPaneRight: "consume-or-expel-pane-right",
73 }75 }
74 76
75 var actionsByName = func() map[string]Action {77 var actionsByName = func() map[string]Action {
M internal/usecase/keys/keyspec_test.go +2 -0
@@ -84,6 +84,8 @@ func TestActionNamesAreCanonical(t *testing.T) {
8484 {ActionGrowPaneHeight, "grow-pane-height"},
8585 {ActionShrinkPaneHeight, "shrink-pane-height"},
8686 {ActionEqualizePanes, "equalize-panes"},
87+ {ActionConsumeOrExpelPaneLeft, "consume-or-expel-pane-left"},
88+ {ActionConsumeOrExpelPaneRight, "consume-or-expel-pane-right"},
8789 }
8890
8991 for _, tc := range cases {
@@ -84,6 +84,8 @@ func TestActionNamesAreCanonical(t *testing.T) {
84 {ActionGrowPaneHeight, "grow-pane-height"},84 {ActionGrowPaneHeight, "grow-pane-height"},
85 {ActionShrinkPaneHeight, "shrink-pane-height"},85 {ActionShrinkPaneHeight, "shrink-pane-height"},
86 {ActionEqualizePanes, "equalize-panes"},86 {ActionEqualizePanes, "equalize-panes"},
87+ {ActionConsumeOrExpelPaneLeft, "consume-or-expel-pane-left"},
88+ {ActionConsumeOrExpelPaneRight, "consume-or-expel-pane-right"},
87 }89 }
88 90
89 for _, tc := range cases {91 for _, tc := range cases {
M internal/usecase/keys/router.go +2 -0
@@ -43,6 +43,8 @@ const (
4343 ActionGrowPaneHeight
4444 ActionShrinkPaneHeight
4545 ActionEqualizePanes
46+ ActionConsumeOrExpelPaneLeft
47+ ActionConsumeOrExpelPaneRight
4648 )
4749
4850 // Handler receives router outputs. Forward is called only for bytes that should
@@ -43,6 +43,8 @@ const (
43 ActionGrowPaneHeight43 ActionGrowPaneHeight
44 ActionShrinkPaneHeight44 ActionShrinkPaneHeight
45 ActionEqualizePanes45 ActionEqualizePanes
46+ ActionConsumeOrExpelPaneLeft
47+ ActionConsumeOrExpelPaneRight
46 )48 )
47 49
48 // Handler receives router outputs. Forward is called only for bytes that should50 // Handler receives router outputs. Forward is called only for bytes that should
A internal/usecase/layout/consume_expel.go +260 -0
@@ -0,0 +1,260 @@
1+package layout
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+var (
10+ ErrUnsupportedColumnLayout = errors.New("unsupported column layout")
11+ ErrInvalidDirection = errors.New("invalid direction")
12+)
13+
14+// ConsumeOrExpelPane moves target horizontally between canonical root columns.
15+// A singleton column is consumed into its immediate neighbor; a member of a
16+// vertical or stack column is expelled into a new singleton column.
17+func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) {
18+ if dir != Left && dir != Right {
19+ return false, ErrInvalidDirection
20+ }
21+ if !validCanonicalColumnTree(t) {
22+ return false, ErrUnsupportedColumnLayout
23+ }
24+
25+ columns := canonicalColumns(t.Root)
26+ columnIndex, _, found := findColumnMember(columns, target)
27+ if !found {
28+ return false, ErrNotFound
29+ }
30+
31+ if columns[columnIndex].Kind == Leaf {
32+ destinationIndex := columnIndex - 1
33+ if dir == Right {
34+ destinationIndex = columnIndex + 1
35+ }
36+ if destinationIndex < 0 || destinationIndex >= len(columns) {
37+ return false, nil
38+ }
39+ }
40+
41+ candidate := t.clone()
42+ if _, solved := Solve(candidate.Root, area); !solved {
43+ return false, ErrTooSmall
44+ }
45+
46+ columns = canonicalColumns(candidate.Root)
47+ columnIndex, memberIndex, _ := findColumnMember(columns, target)
48+ if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal {
49+ normalizeChildWeightsFromArea(candidate.Root, area)
50+ }
51+
52+ if columns[columnIndex].Kind == Leaf {
53+ consumeSingletonColumn(candidate, columnIndex, dir, area)
54+ } else {
55+ expelColumnMember(candidate, columnIndex, memberIndex, dir)
56+ }
57+ candidate.Focus = target
58+
59+ if _, solved := Solve(candidate.Root, area); !solved {
60+ return false, ErrTooSmall
61+ }
62+ *t = *candidate
63+ return true, nil
64+}
65+
66+func validCanonicalColumnTree(t *Tree) bool {
67+ if t == nil || t.Root == nil {
68+ return false
69+ }
70+
71+ columns := canonicalColumns(t.Root)
72+ if len(columns) == 0 {
73+ return false
74+ }
75+ if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 {
76+ return false
77+ }
78+
79+ ids := make(map[PaneID]struct{})
80+ for _, column := range columns {
81+ if !validCanonicalColumn(column, ids) {
82+ return false
83+ }
84+ }
85+ if t.Focus == "" {
86+ return false
87+ }
88+ _, focused := ids[t.Focus]
89+ return focused
90+}
91+
92+func canonicalColumns(root *Node) []*Node {
93+ if root == nil {
94+ return nil
95+ }
96+ if root.Kind == Split && root.Dir == Horizontal {
97+ return root.Children
98+ }
99+ return []*Node{root}
100+}
101+
102+func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool {
103+ if column == nil {
104+ return false
105+ }
106+
107+ switch column.Kind {
108+ case Leaf:
109+ if len(column.Children) != 0 {
110+ return false
111+ }
112+ return addUniqueLeafID(ids, column.Leaf)
113+ case Split:
114+ if column.Dir != Vertical || len(column.Children) < 2 {
115+ return false
116+ }
117+ case Stack:
118+ if len(column.Children) < 2 {
119+ return false
120+ }
121+ default:
122+ return false
123+ }
124+
125+ expanded := column.Kind != Stack
126+ for _, member := range column.Children {
127+ if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) {
128+ return false
129+ }
130+ if column.Kind == Stack && member.Leaf == column.Expanded {
131+ expanded = true
132+ }
133+ }
134+ return expanded
135+}
136+
137+func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool {
138+ if id == "" {
139+ return false
140+ }
141+ if _, exists := ids[id]; exists {
142+ return false
143+ }
144+ ids[id] = struct{}{}
145+ return true
146+}
147+
148+func findColumnMember(columns []*Node, target PaneID) (int, int, bool) {
149+ for columnIndex, column := range columns {
150+ if column.Kind == Leaf {
151+ if column.Leaf == target {
152+ return columnIndex, -1, true
153+ }
154+ continue
155+ }
156+ for memberIndex, member := range column.Children {
157+ if member.Leaf == target {
158+ return columnIndex, memberIndex, true
159+ }
160+ }
161+ }
162+ return 0, 0, false
163+}
164+
165+func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {
166+ root := t.Root
167+ destinationIndex := sourceIndex - 1
168+ if dir == Right {
169+ destinationIndex = sourceIndex + 1
170+ }
171+
172+ columnAreas, _ := splitChildRects(root, area)
173+ moved := root.Children[sourceIndex]
174+ moved.Weight = 0
175+ destination := root.Children[destinationIndex]
176+
177+ switch destination.Kind {
178+ case Leaf:
179+ weight := destination.Weight
180+ destination.Weight = 0
181+ destination = &Node{
182+ Kind: Split,
183+ Dir: Vertical,
184+ Children: []*Node{destination, moved},
185+ Weight: weight,
186+ }
187+ case Split:
188+ normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])
189+ destination.Children = append(destination.Children, moved)
190+ case Stack:
191+ destination.Children = append(destination.Children, moved)
192+ destination.Expanded = moved.Leaf
193+ clearChildWeights(destination)
194+ }
195+ root.Children[destinationIndex] = destination
196+ root.Children = removeNodeAt(root.Children, sourceIndex)
197+
198+ if len(root.Children) == 1 {
199+ promoted := root.Children[0]
200+ promoted.Weight = root.Weight
201+ t.Root = promoted
202+ }
203+}
204+
205+func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) {
206+ root := t.Root
207+ rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal
208+ columns := canonicalColumns(root)
209+ source := columns[columnIndex]
210+ wrapperWeight := source.Weight
211+ if !rootIsHorizontal {
212+ wrapperWeight = root.Weight
213+ }
214+
215+ moved := source.Children[memberIndex]
216+ moved.Weight = 0
217+ source.Children = removeNodeAt(source.Children, memberIndex)
218+ if source.Kind == Stack {
219+ if source.Expanded == moved.Leaf {
220+ source.Expanded = source.Children[0].Leaf
221+ }
222+ clearChildWeights(source)
223+ }
224+
225+ remaining := source
226+ if len(source.Children) == 1 {
227+ remaining = source.Children[0]
228+ remaining.Weight = source.Weight
229+ }
230+
231+ if !rootIsHorizontal {
232+ remaining.Weight = 0
233+ children := []*Node{remaining, moved}
234+ if dir == Left {
235+ children[0], children[1] = children[1], children[0]
236+ }
237+ t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight}
238+ return
239+ }
240+
241+ root.Children[columnIndex] = remaining
242+ insertionIndex := columnIndex
243+ if dir == Right {
244+ insertionIndex++
245+ }
246+ root.Children = insertNodeAt(root.Children, insertionIndex, moved)
247+}
248+
249+func removeNodeAt(nodes []*Node, index int) []*Node {
250+ copy(nodes[index:], nodes[index+1:])
251+ nodes[len(nodes)-1] = nil
252+ return nodes[:len(nodes)-1]
253+}
254+
255+func insertNodeAt(nodes []*Node, index int, node *Node) []*Node {
256+ nodes = append(nodes, nil)
257+ copy(nodes[index+1:], nodes[index:])
258+ nodes[index] = node
259+ return nodes
260+}
@@ -0,0 +1,260 @@
1+package layout
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+var (
10+ ErrUnsupportedColumnLayout = errors.New("unsupported column layout")
11+ ErrInvalidDirection = errors.New("invalid direction")
12+)
13+
14+// ConsumeOrExpelPane moves target horizontally between canonical root columns.
15+// A singleton column is consumed into its immediate neighbor; a member of a
16+// vertical or stack column is expelled into a new singleton column.
17+func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) {
18+ if dir != Left && dir != Right {
19+ return false, ErrInvalidDirection
20+ }
21+ if !validCanonicalColumnTree(t) {
22+ return false, ErrUnsupportedColumnLayout
23+ }
24+
25+ columns := canonicalColumns(t.Root)
26+ columnIndex, _, found := findColumnMember(columns, target)
27+ if !found {
28+ return false, ErrNotFound
29+ }
30+
31+ if columns[columnIndex].Kind == Leaf {
32+ destinationIndex := columnIndex - 1
33+ if dir == Right {
34+ destinationIndex = columnIndex + 1
35+ }
36+ if destinationIndex < 0 || destinationIndex >= len(columns) {
37+ return false, nil
38+ }
39+ }
40+
41+ candidate := t.clone()
42+ if _, solved := Solve(candidate.Root, area); !solved {
43+ return false, ErrTooSmall
44+ }
45+
46+ columns = canonicalColumns(candidate.Root)
47+ columnIndex, memberIndex, _ := findColumnMember(columns, target)
48+ if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal {
49+ normalizeChildWeightsFromArea(candidate.Root, area)
50+ }
51+
52+ if columns[columnIndex].Kind == Leaf {
53+ consumeSingletonColumn(candidate, columnIndex, dir, area)
54+ } else {
55+ expelColumnMember(candidate, columnIndex, memberIndex, dir)
56+ }
57+ candidate.Focus = target
58+
59+ if _, solved := Solve(candidate.Root, area); !solved {
60+ return false, ErrTooSmall
61+ }
62+ *t = *candidate
63+ return true, nil
64+}
65+
66+func validCanonicalColumnTree(t *Tree) bool {
67+ if t == nil || t.Root == nil {
68+ return false
69+ }
70+
71+ columns := canonicalColumns(t.Root)
72+ if len(columns) == 0 {
73+ return false
74+ }
75+ if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 {
76+ return false
77+ }
78+
79+ ids := make(map[PaneID]struct{})
80+ for _, column := range columns {
81+ if !validCanonicalColumn(column, ids) {
82+ return false
83+ }
84+ }
85+ if t.Focus == "" {
86+ return false
87+ }
88+ _, focused := ids[t.Focus]
89+ return focused
90+}
91+
92+func canonicalColumns(root *Node) []*Node {
93+ if root == nil {
94+ return nil
95+ }
96+ if root.Kind == Split && root.Dir == Horizontal {
97+ return root.Children
98+ }
99+ return []*Node{root}
100+}
101+
102+func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool {
103+ if column == nil {
104+ return false
105+ }
106+
107+ switch column.Kind {
108+ case Leaf:
109+ if len(column.Children) != 0 {
110+ return false
111+ }
112+ return addUniqueLeafID(ids, column.Leaf)
113+ case Split:
114+ if column.Dir != Vertical || len(column.Children) < 2 {
115+ return false
116+ }
117+ case Stack:
118+ if len(column.Children) < 2 {
119+ return false
120+ }
121+ default:
122+ return false
123+ }
124+
125+ expanded := column.Kind != Stack
126+ for _, member := range column.Children {
127+ if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) {
128+ return false
129+ }
130+ if column.Kind == Stack && member.Leaf == column.Expanded {
131+ expanded = true
132+ }
133+ }
134+ return expanded
135+}
136+
137+func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool {
138+ if id == "" {
139+ return false
140+ }
141+ if _, exists := ids[id]; exists {
142+ return false
143+ }
144+ ids[id] = struct{}{}
145+ return true
146+}
147+
148+func findColumnMember(columns []*Node, target PaneID) (int, int, bool) {
149+ for columnIndex, column := range columns {
150+ if column.Kind == Leaf {
151+ if column.Leaf == target {
152+ return columnIndex, -1, true
153+ }
154+ continue
155+ }
156+ for memberIndex, member := range column.Children {
157+ if member.Leaf == target {
158+ return columnIndex, memberIndex, true
159+ }
160+ }
161+ }
162+ return 0, 0, false
163+}
164+
165+func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {
166+ root := t.Root
167+ destinationIndex := sourceIndex - 1
168+ if dir == Right {
169+ destinationIndex = sourceIndex + 1
170+ }
171+
172+ columnAreas, _ := splitChildRects(root, area)
173+ moved := root.Children[sourceIndex]
174+ moved.Weight = 0
175+ destination := root.Children[destinationIndex]
176+
177+ switch destination.Kind {
178+ case Leaf:
179+ weight := destination.Weight
180+ destination.Weight = 0
181+ destination = &Node{
182+ Kind: Split,
183+ Dir: Vertical,
184+ Children: []*Node{destination, moved},
185+ Weight: weight,
186+ }
187+ case Split:
188+ normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])
189+ destination.Children = append(destination.Children, moved)
190+ case Stack:
191+ destination.Children = append(destination.Children, moved)
192+ destination.Expanded = moved.Leaf
193+ clearChildWeights(destination)
194+ }
195+ root.Children[destinationIndex] = destination
196+ root.Children = removeNodeAt(root.Children, sourceIndex)
197+
198+ if len(root.Children) == 1 {
199+ promoted := root.Children[0]
200+ promoted.Weight = root.Weight
201+ t.Root = promoted
202+ }
203+}
204+
205+func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) {
206+ root := t.Root
207+ rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal
208+ columns := canonicalColumns(root)
209+ source := columns[columnIndex]
210+ wrapperWeight := source.Weight
211+ if !rootIsHorizontal {
212+ wrapperWeight = root.Weight
213+ }
214+
215+ moved := source.Children[memberIndex]
216+ moved.Weight = 0
217+ source.Children = removeNodeAt(source.Children, memberIndex)
218+ if source.Kind == Stack {
219+ if source.Expanded == moved.Leaf {
220+ source.Expanded = source.Children[0].Leaf
221+ }
222+ clearChildWeights(source)
223+ }
224+
225+ remaining := source
226+ if len(source.Children) == 1 {
227+ remaining = source.Children[0]
228+ remaining.Weight = source.Weight
229+ }
230+
231+ if !rootIsHorizontal {
232+ remaining.Weight = 0
233+ children := []*Node{remaining, moved}
234+ if dir == Left {
235+ children[0], children[1] = children[1], children[0]
236+ }
237+ t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight}
238+ return
239+ }
240+
241+ root.Children[columnIndex] = remaining
242+ insertionIndex := columnIndex
243+ if dir == Right {
244+ insertionIndex++
245+ }
246+ root.Children = insertNodeAt(root.Children, insertionIndex, moved)
247+}
248+
249+func removeNodeAt(nodes []*Node, index int) []*Node {
250+ copy(nodes[index:], nodes[index+1:])
251+ nodes[len(nodes)-1] = nil
252+ return nodes[:len(nodes)-1]
253+}
254+
255+func insertNodeAt(nodes []*Node, index int, node *Node) []*Node {
256+ nodes = append(nodes, nil)
257+ copy(nodes[index+1:], nodes[index:])
258+ nodes[index] = node
259+ return nodes
260+}
A internal/usecase/layout/consume_expel_test.go +304 -0
@@ -0,0 +1,304 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestConsumeOrExpelPaneTransitions(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{Width: 101, Height: 12}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ target PaneID
18+ dir Direction
19+ want *Tree
20+ wantChange bool
21+ }{
22+ {
23+ name: "consume singleton left into leaf",
24+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
25+ target: "b",
26+ dir: Left,
27+ want: &Tree{Root: vertical("a", "b"), Focus: "b"},
28+ wantChange: true,
29+ },
30+ {
31+ name: "consume singleton right into leaf",
32+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
33+ target: "a",
34+ dir: Right,
35+ want: &Tree{Root: vertical("b", "a"), Focus: "a"},
36+ wantChange: true,
37+ },
38+ {
39+ name: "consume singleton left into vertical column",
40+ tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"},
41+ target: "c",
42+ dir: Left,
43+ want: &Tree{Root: verticalNodes(
44+ consumeExpelWeightedLeaf("a", 6),
45+ consumeExpelWeightedLeaf("b", 5),
46+ NewLeaf("c"),
47+ ), Focus: "c"},
48+ wantChange: true,
49+ },
50+ {
51+ name: "consume singleton right into vertical column",
52+ tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"},
53+ target: "a",
54+ dir: Right,
55+ want: &Tree{Root: verticalNodes(
56+ consumeExpelWeightedLeaf("b", 6),
57+ consumeExpelWeightedLeaf("c", 5),
58+ NewLeaf("a"),
59+ ), Focus: "a"},
60+ wantChange: true,
61+ },
62+ {
63+ name: "consume singleton left into stack appends and expands",
64+ tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"},
65+ target: "c",
66+ dir: Left,
67+ want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"},
68+ wantChange: true,
69+ },
70+ {
71+ name: "consume singleton right into stack appends and expands",
72+ tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"},
73+ target: "a",
74+ dir: Right,
75+ want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"},
76+ wantChange: true,
77+ },
78+ {
79+ name: "expel middle vertical member left from sole root column",
80+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
81+ target: "b",
82+ dir: Left,
83+ want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"},
84+ wantChange: true,
85+ },
86+ {
87+ name: "expel middle vertical member right from sole root column",
88+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
89+ target: "b",
90+ dir: Right,
91+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
92+ wantChange: true,
93+ },
94+ {
95+ name: "expel vertical member left beside its source column",
96+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
97+ target: "b",
98+ dir: Left,
99+ want: &Tree{Root: horizontal(
100+ NewLeaf("b"),
101+ consumeExpelWeightedNode(vertical("a", "c"), 50),
102+ consumeExpelWeightedLeaf("d", 50),
103+ ), Focus: "b"},
104+ wantChange: true,
105+ },
106+ {
107+ name: "expel vertical member right beside its source column",
108+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
109+ target: "b",
110+ dir: Right,
111+ want: &Tree{Root: horizontal(
112+ consumeExpelWeightedNode(vertical("a", "c"), 50),
113+ NewLeaf("b"),
114+ consumeExpelWeightedLeaf("d", 50),
115+ ), Focus: "b"},
116+ wantChange: true,
117+ },
118+ {
119+ name: "expel stack member left from sole root column",
120+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
121+ target: "b",
122+ dir: Left,
123+ want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"},
124+ wantChange: true,
125+ },
126+ {
127+ name: "expel stack member right from sole root column",
128+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
129+ target: "b",
130+ dir: Right,
131+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
132+ wantChange: true,
133+ },
134+ {
135+ name: "expelling expanded stack member repairs source expansion",
136+ tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"},
137+ target: "b",
138+ dir: Right,
139+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
140+ wantChange: true,
141+ },
142+ {
143+ name: "expel collapses two-member vertical column",
144+ tree: &Tree{Root: vertical("a", "b"), Focus: "a"},
145+ target: "a",
146+ dir: Left,
147+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
148+ wantChange: true,
149+ },
150+ {
151+ name: "expel collapses two-member stack column",
152+ tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"},
153+ target: "b",
154+ dir: Right,
155+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
156+ wantChange: true,
157+ },
158+ {
159+ name: "real move focuses explicitly targeted non-focused pane",
160+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"},
161+ target: "b",
162+ dir: Right,
163+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
164+ wantChange: true,
165+ },
166+ {
167+ name: "left edge singleton is exact no-op and preserves different focus",
168+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
169+ target: "a",
170+ dir: Left,
171+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
172+ wantChange: false,
173+ },
174+ {
175+ name: "right edge singleton is exact no-op and preserves different focus",
176+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
177+ target: "b",
178+ dir: Right,
179+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
180+ wantChange: false,
181+ },
182+ {
183+ name: "sole singleton root is exact no-op",
184+ tree: NewTree("a"),
185+ target: "a",
186+ dir: Left,
187+ want: NewTree("a"),
188+ wantChange: false,
189+ },
190+ }
191+
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ before := tt.tree.Clone()
195+ beforeIDs := LeafIDs(tt.tree.Root)
196+ requireUniquePaneIDs(t, beforeIDs)
197+
198+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area)
199+ require.NoError(t, err)
200+ require.Equal(t, tt.wantChange, changed)
201+ require.Equal(t, tt.want, tt.tree)
202+ if !tt.wantChange {
203+ require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree")
204+ }
205+
206+ afterIDs := LeafIDs(tt.tree.Root)
207+ require.ElementsMatch(t, beforeIDs, afterIDs)
208+ require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs")
209+ requireUniquePaneIDs(t, afterIDs)
210+ _, solved := Solve(tt.tree.Root, area)
211+ require.True(t, solved, "every successful result must solve")
212+ })
213+ }
214+}
215+
216+func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) {
217+ t.Parallel()
218+
219+ area := domain.Rect{Width: 101, Height: 12}
220+ tests := []struct {
221+ name string
222+ tree *Tree
223+ target PaneID
224+ dir Direction
225+ area domain.Rect
226+ want error
227+ }{
228+ {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
229+ {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
230+ {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound},
231+ {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
232+ {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout},
233+ {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
234+ {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
235+ {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
236+ {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
237+ {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
238+ {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
239+ {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
240+ {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
241+ {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
242+ {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
243+ {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
244+ {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
245+ {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection},
246+ {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection},
247+ {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection},
248+ {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall},
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ before := tt.tree.Clone()
254+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
255+ require.False(t, changed)
256+ require.ErrorIs(t, err, tt.want)
257+ require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree")
258+ })
259+ }
260+}
261+
262+func horizontal(columns ...*Node) *Node {
263+ return &Node{Kind: Split, Dir: Horizontal, Children: columns}
264+}
265+
266+func vertical(ids ...PaneID) *Node {
267+ children := make([]*Node, 0, len(ids))
268+ for _, id := range ids {
269+ children = append(children, NewLeaf(id))
270+ }
271+ return verticalNodes(children...)
272+}
273+
274+func verticalNodes(children ...*Node) *Node {
275+ return &Node{Kind: Split, Dir: Vertical, Children: children}
276+}
277+
278+func stack(expanded PaneID, ids ...PaneID) *Node {
279+ children := make([]*Node, 0, len(ids))
280+ for _, id := range ids {
281+ children = append(children, NewLeaf(id))
282+ }
283+ return &Node{Kind: Stack, Children: children, Expanded: expanded}
284+}
285+
286+func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node {
287+ return &Node{Kind: Leaf, Leaf: id, Weight: weight}
288+}
289+
290+func consumeExpelWeightedNode(node *Node, weight float64) *Node {
291+ node.Weight = weight
292+ return node
293+}
294+
295+func requireUniquePaneIDs(t *testing.T, ids []PaneID) {
296+ t.Helper()
297+ seen := make(map[PaneID]struct{}, len(ids))
298+ for _, id := range ids {
299+ require.NotEmpty(t, id)
300+ _, duplicate := seen[id]
301+ require.False(t, duplicate, "pane ID %q occurs more than once", id)
302+ seen[id] = struct{}{}
303+ }
304+}
@@ -0,0 +1,304 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestConsumeOrExpelPaneTransitions(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{Width: 101, Height: 12}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ target PaneID
18+ dir Direction
19+ want *Tree
20+ wantChange bool
21+ }{
22+ {
23+ name: "consume singleton left into leaf",
24+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
25+ target: "b",
26+ dir: Left,
27+ want: &Tree{Root: vertical("a", "b"), Focus: "b"},
28+ wantChange: true,
29+ },
30+ {
31+ name: "consume singleton right into leaf",
32+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
33+ target: "a",
34+ dir: Right,
35+ want: &Tree{Root: vertical("b", "a"), Focus: "a"},
36+ wantChange: true,
37+ },
38+ {
39+ name: "consume singleton left into vertical column",
40+ tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"},
41+ target: "c",
42+ dir: Left,
43+ want: &Tree{Root: verticalNodes(
44+ consumeExpelWeightedLeaf("a", 6),
45+ consumeExpelWeightedLeaf("b", 5),
46+ NewLeaf("c"),
47+ ), Focus: "c"},
48+ wantChange: true,
49+ },
50+ {
51+ name: "consume singleton right into vertical column",
52+ tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"},
53+ target: "a",
54+ dir: Right,
55+ want: &Tree{Root: verticalNodes(
56+ consumeExpelWeightedLeaf("b", 6),
57+ consumeExpelWeightedLeaf("c", 5),
58+ NewLeaf("a"),
59+ ), Focus: "a"},
60+ wantChange: true,
61+ },
62+ {
63+ name: "consume singleton left into stack appends and expands",
64+ tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"},
65+ target: "c",
66+ dir: Left,
67+ want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"},
68+ wantChange: true,
69+ },
70+ {
71+ name: "consume singleton right into stack appends and expands",
72+ tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"},
73+ target: "a",
74+ dir: Right,
75+ want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"},
76+ wantChange: true,
77+ },
78+ {
79+ name: "expel middle vertical member left from sole root column",
80+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
81+ target: "b",
82+ dir: Left,
83+ want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"},
84+ wantChange: true,
85+ },
86+ {
87+ name: "expel middle vertical member right from sole root column",
88+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
89+ target: "b",
90+ dir: Right,
91+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
92+ wantChange: true,
93+ },
94+ {
95+ name: "expel vertical member left beside its source column",
96+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
97+ target: "b",
98+ dir: Left,
99+ want: &Tree{Root: horizontal(
100+ NewLeaf("b"),
101+ consumeExpelWeightedNode(vertical("a", "c"), 50),
102+ consumeExpelWeightedLeaf("d", 50),
103+ ), Focus: "b"},
104+ wantChange: true,
105+ },
106+ {
107+ name: "expel vertical member right beside its source column",
108+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
109+ target: "b",
110+ dir: Right,
111+ want: &Tree{Root: horizontal(
112+ consumeExpelWeightedNode(vertical("a", "c"), 50),
113+ NewLeaf("b"),
114+ consumeExpelWeightedLeaf("d", 50),
115+ ), Focus: "b"},
116+ wantChange: true,
117+ },
118+ {
119+ name: "expel stack member left from sole root column",
120+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
121+ target: "b",
122+ dir: Left,
123+ want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"},
124+ wantChange: true,
125+ },
126+ {
127+ name: "expel stack member right from sole root column",
128+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
129+ target: "b",
130+ dir: Right,
131+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
132+ wantChange: true,
133+ },
134+ {
135+ name: "expelling expanded stack member repairs source expansion",
136+ tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"},
137+ target: "b",
138+ dir: Right,
139+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
140+ wantChange: true,
141+ },
142+ {
143+ name: "expel collapses two-member vertical column",
144+ tree: &Tree{Root: vertical("a", "b"), Focus: "a"},
145+ target: "a",
146+ dir: Left,
147+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
148+ wantChange: true,
149+ },
150+ {
151+ name: "expel collapses two-member stack column",
152+ tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"},
153+ target: "b",
154+ dir: Right,
155+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
156+ wantChange: true,
157+ },
158+ {
159+ name: "real move focuses explicitly targeted non-focused pane",
160+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"},
161+ target: "b",
162+ dir: Right,
163+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
164+ wantChange: true,
165+ },
166+ {
167+ name: "left edge singleton is exact no-op and preserves different focus",
168+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
169+ target: "a",
170+ dir: Left,
171+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
172+ wantChange: false,
173+ },
174+ {
175+ name: "right edge singleton is exact no-op and preserves different focus",
176+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
177+ target: "b",
178+ dir: Right,
179+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
180+ wantChange: false,
181+ },
182+ {
183+ name: "sole singleton root is exact no-op",
184+ tree: NewTree("a"),
185+ target: "a",
186+ dir: Left,
187+ want: NewTree("a"),
188+ wantChange: false,
189+ },
190+ }
191+
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ before := tt.tree.Clone()
195+ beforeIDs := LeafIDs(tt.tree.Root)
196+ requireUniquePaneIDs(t, beforeIDs)
197+
198+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area)
199+ require.NoError(t, err)
200+ require.Equal(t, tt.wantChange, changed)
201+ require.Equal(t, tt.want, tt.tree)
202+ if !tt.wantChange {
203+ require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree")
204+ }
205+
206+ afterIDs := LeafIDs(tt.tree.Root)
207+ require.ElementsMatch(t, beforeIDs, afterIDs)
208+ require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs")
209+ requireUniquePaneIDs(t, afterIDs)
210+ _, solved := Solve(tt.tree.Root, area)
211+ require.True(t, solved, "every successful result must solve")
212+ })
213+ }
214+}
215+
216+func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) {
217+ t.Parallel()
218+
219+ area := domain.Rect{Width: 101, Height: 12}
220+ tests := []struct {
221+ name string
222+ tree *Tree
223+ target PaneID
224+ dir Direction
225+ area domain.Rect
226+ want error
227+ }{
228+ {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
229+ {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
230+ {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound},
231+ {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
232+ {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout},
233+ {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
234+ {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
235+ {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
236+ {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
237+ {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
238+ {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
239+ {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
240+ {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
241+ {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
242+ {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
243+ {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
244+ {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
245+ {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection},
246+ {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection},
247+ {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection},
248+ {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall},
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ before := tt.tree.Clone()
254+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
255+ require.False(t, changed)
256+ require.ErrorIs(t, err, tt.want)
257+ require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree")
258+ })
259+ }
260+}
261+
262+func horizontal(columns ...*Node) *Node {
263+ return &Node{Kind: Split, Dir: Horizontal, Children: columns}
264+}
265+
266+func vertical(ids ...PaneID) *Node {
267+ children := make([]*Node, 0, len(ids))
268+ for _, id := range ids {
269+ children = append(children, NewLeaf(id))
270+ }
271+ return verticalNodes(children...)
272+}
273+
274+func verticalNodes(children ...*Node) *Node {
275+ return &Node{Kind: Split, Dir: Vertical, Children: children}
276+}
277+
278+func stack(expanded PaneID, ids ...PaneID) *Node {
279+ children := make([]*Node, 0, len(ids))
280+ for _, id := range ids {
281+ children = append(children, NewLeaf(id))
282+ }
283+ return &Node{Kind: Stack, Children: children, Expanded: expanded}
284+}
285+
286+func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node {
287+ return &Node{Kind: Leaf, Leaf: id, Weight: weight}
288+}
289+
290+func consumeExpelWeightedNode(node *Node, weight float64) *Node {
291+ node.Weight = weight
292+ return node
293+}
294+
295+func requireUniquePaneIDs(t *testing.T, ids []PaneID) {
296+ t.Helper()
297+ seen := make(map[PaneID]struct{}, len(ids))
298+ for _, id := range ids {
299+ require.NotEmpty(t, id)
300+ _, duplicate := seen[id]
301+ require.False(t, duplicate, "pane ID %q occurs more than once", id)
302+ seen[id] = struct{}{}
303+ }
304+}
M internal/usecase/layout/weights_test.go +166 -0
@@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
323323 }
324324 }
325325
326+func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
327+ t.Parallel()
328+
329+ tests := []struct {
330+ name string
331+ tree *Tree
332+ target PaneID
333+ dir Direction
334+ area domain.Rect
335+ check func(*testing.T, *Tree, []Placement, []Placement)
336+ }{
337+ {
338+ name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight",
339+ tree: &Tree{Root: weightedNode(horizontal(
340+ weightedNode(vertical("a", "b"), 3),
341+ weightedLeaf("c", 1),
342+ ), 11), Focus: "b"},
343+ target: "b",
344+ dir: Right,
345+ area: domain.Rect{Width: 202, Height: 5},
346+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
347+ require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))
348+ require.Equal(t, 11.0, tree.Root.Weight)
349+ require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root))
350+ require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
351+ require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after))
352+ },
353+ },
354+ {
355+ name: "vertical consume preserves solved member shares and gives moved pane default weight",
356+ tree: &Tree{Root: weightedNode(horizontal(
357+ weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),
358+ weightedLeaf("c", 1),
359+ ), 9), Focus: "c"},
360+ target: "c",
361+ dir: Left,
362+ area: domain.Rect{Width: 101, Height: 20},
363+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
364+ require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))
365+ require.Equal(t, Vertical, tree.Root.Dir)
366+ require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")
367+ require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root))
368+ require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after))
369+ },
370+ },
371+ {
372+ name: "stack consume clears member weights and preserves normalized outer column share",
373+ tree: &Tree{Root: horizontal(
374+ weightedLeaf("x", 1),
375+ weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3),
376+ weightedLeaf("c", 1),
377+ ), Focus: "c"},
378+ target: "c",
379+ dir: Left,
380+ area: domain.Rect{Width: 103, Height: 4},
381+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
382+ require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before))
383+ require.Len(t, tree.Root.Children, 2)
384+ stackColumn := tree.Root.Children[1]
385+ require.Equal(t, Stack, stackColumn.Kind)
386+ require.Equal(t, 61.0, stackColumn.Weight)
387+ require.Equal(t, PaneID("c"), stackColumn.Expanded)
388+ require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn))
389+ require.Equal(t, 77, placementSpanWidth(after, "a"))
390+ require.Equal(t, 77, placementSpanWidth(after, "b"))
391+ require.Equal(t, 77, placementSpanWidth(after, "c"))
392+ },
393+ },
394+ {
395+ name: "stack expel clears remaining weights and repairs expanded member",
396+ tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{
397+ weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3),
398+ }, Expanded: "b"}, 7), Focus: "b"},
399+ target: "b",
400+ dir: Right,
401+ area: domain.Rect{Width: 101, Height: 4},
402+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
403+ require.Equal(t, Horizontal, tree.Root.Dir)
404+ require.Equal(t, 7.0, tree.Root.Weight)
405+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
406+ stackColumn := tree.Root.Children[0]
407+ require.Equal(t, PaneID("a"), stackColumn.Expanded)
408+ require.Equal(t, []float64{0, 0}, childWeights(stackColumn))
409+ require.Equal(t, 50, placementSpanWidth(after, "a"))
410+ require.Equal(t, 50, placementSpanWidth(after, "c"))
411+ require.Equal(t, 50, placementSpanWidth(after, "b"))
412+ },
413+ },
414+ {
415+ name: "column collapse copies normalized container weight to promoted leaf",
416+ tree: &Tree{Root: horizontal(
417+ weightedLeaf("x", 1),
418+ weightedNode(vertical("a", "b"), 3),
419+ weightedLeaf("y", 1),
420+ ), Focus: "a"},
421+ target: "a",
422+ dir: Left,
423+ area: domain.Rect{Width: 103, Height: 5},
424+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
425+ require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root))
426+ require.Equal(t, Leaf, tree.Root.Children[2].Kind)
427+ require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)
428+ require.Equal(t, 61.0, tree.Root.Children[2].Weight)
429+ require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root))
430+ require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after))
431+ },
432+ },
433+ {
434+ name: "sole-column expel creates equal default horizontal children and retains wrapper weight",
435+ tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"},
436+ target: "a",
437+ dir: Left,
438+ area: domain.Rect{Width: 101, Height: 5},
439+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
440+ require.Equal(t, Horizontal, tree.Root.Dir)
441+ require.Equal(t, 9.0, tree.Root.Weight)
442+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
443+ require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))
444+ },
445+ },
446+ }
447+
448+ for _, tt := range tests {
449+ t.Run(tt.name, func(t *testing.T) {
450+ beforeIDs := LeafIDs(tt.tree.Root)
451+ requireUniquePaneIDs(t, beforeIDs)
452+ before, solved := Solve(tt.tree.Root, tt.area)
453+ require.True(t, solved, "weight fixture must solve before surgery")
454+
455+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
456+ require.NoError(t, err)
457+ require.True(t, changed)
458+ require.Equal(t, tt.target, tt.tree.Focus)
459+
460+ afterIDs := LeafIDs(tt.tree.Root)
461+ require.ElementsMatch(t, beforeIDs, afterIDs)
462+ require.Len(t, afterIDs, len(beforeIDs))
463+ requireUniquePaneIDs(t, afterIDs)
464+ after, solved := Solve(tt.tree.Root, tt.area)
465+ require.True(t, solved, "weighted result must solve")
466+ tt.check(t, tt.tree, before, after)
467+ })
468+ }
469+}
470+
471+func childWeights(node *Node) []float64 {
472+ weights := make([]float64, len(node.Children))
473+ for i, child := range node.Children {
474+ weights[i] = child.Weight
475+ }
476+ return weights
477+}
478+
479+func placementSpanWidth(placements []Placement, id PaneID) int {
480+ for _, placement := range placements {
481+ if placement.ID != id {
482+ continue
483+ }
484+ if placement.Content.Width > 0 {
485+ return placement.Content.Width
486+ }
487+ return placement.TitleBar.Width
488+ }
489+ return 0
490+}
491+
326492 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
327493 t.Parallel()
328494
@@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
323 }323 }
324 }324 }
325 325
326+func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
327+ t.Parallel()
328+
329+ tests := []struct {
330+ name string
331+ tree *Tree
332+ target PaneID
333+ dir Direction
334+ area domain.Rect
335+ check func(*testing.T, *Tree, []Placement, []Placement)
336+ }{
337+ {
338+ name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight",
339+ tree: &Tree{Root: weightedNode(horizontal(
340+ weightedNode(vertical("a", "b"), 3),
341+ weightedLeaf("c", 1),
342+ ), 11), Focus: "b"},
343+ target: "b",
344+ dir: Right,
345+ area: domain.Rect{Width: 202, Height: 5},
346+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
347+ require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))
348+ require.Equal(t, 11.0, tree.Root.Weight)
349+ require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root))
350+ require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
351+ require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after))
352+ },
353+ },
354+ {
355+ name: "vertical consume preserves solved member shares and gives moved pane default weight",
356+ tree: &Tree{Root: weightedNode(horizontal(
357+ weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),
358+ weightedLeaf("c", 1),
359+ ), 9), Focus: "c"},
360+ target: "c",
361+ dir: Left,
362+ area: domain.Rect{Width: 101, Height: 20},
363+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
364+ require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))
365+ require.Equal(t, Vertical, tree.Root.Dir)
366+ require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")
367+ require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root))
368+ require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after))
369+ },
370+ },
371+ {
372+ name: "stack consume clears member weights and preserves normalized outer column share",
373+ tree: &Tree{Root: horizontal(
374+ weightedLeaf("x", 1),
375+ weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3),
376+ weightedLeaf("c", 1),
377+ ), Focus: "c"},
378+ target: "c",
379+ dir: Left,
380+ area: domain.Rect{Width: 103, Height: 4},
381+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
382+ require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before))
383+ require.Len(t, tree.Root.Children, 2)
384+ stackColumn := tree.Root.Children[1]
385+ require.Equal(t, Stack, stackColumn.Kind)
386+ require.Equal(t, 61.0, stackColumn.Weight)
387+ require.Equal(t, PaneID("c"), stackColumn.Expanded)
388+ require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn))
389+ require.Equal(t, 77, placementSpanWidth(after, "a"))
390+ require.Equal(t, 77, placementSpanWidth(after, "b"))
391+ require.Equal(t, 77, placementSpanWidth(after, "c"))
392+ },
393+ },
394+ {
395+ name: "stack expel clears remaining weights and repairs expanded member",
396+ tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{
397+ weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3),
398+ }, Expanded: "b"}, 7), Focus: "b"},
399+ target: "b",
400+ dir: Right,
401+ area: domain.Rect{Width: 101, Height: 4},
402+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
403+ require.Equal(t, Horizontal, tree.Root.Dir)
404+ require.Equal(t, 7.0, tree.Root.Weight)
405+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
406+ stackColumn := tree.Root.Children[0]
407+ require.Equal(t, PaneID("a"), stackColumn.Expanded)
408+ require.Equal(t, []float64{0, 0}, childWeights(stackColumn))
409+ require.Equal(t, 50, placementSpanWidth(after, "a"))
410+ require.Equal(t, 50, placementSpanWidth(after, "c"))
411+ require.Equal(t, 50, placementSpanWidth(after, "b"))
412+ },
413+ },
414+ {
415+ name: "column collapse copies normalized container weight to promoted leaf",
416+ tree: &Tree{Root: horizontal(
417+ weightedLeaf("x", 1),
418+ weightedNode(vertical("a", "b"), 3),
419+ weightedLeaf("y", 1),
420+ ), Focus: "a"},
421+ target: "a",
422+ dir: Left,
423+ area: domain.Rect{Width: 103, Height: 5},
424+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
425+ require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root))
426+ require.Equal(t, Leaf, tree.Root.Children[2].Kind)
427+ require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)
428+ require.Equal(t, 61.0, tree.Root.Children[2].Weight)
429+ require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root))
430+ require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after))
431+ },
432+ },
433+ {
434+ name: "sole-column expel creates equal default horizontal children and retains wrapper weight",
435+ tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"},
436+ target: "a",
437+ dir: Left,
438+ area: domain.Rect{Width: 101, Height: 5},
439+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
440+ require.Equal(t, Horizontal, tree.Root.Dir)
441+ require.Equal(t, 9.0, tree.Root.Weight)
442+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
443+ require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))
444+ },
445+ },
446+ }
447+
448+ for _, tt := range tests {
449+ t.Run(tt.name, func(t *testing.T) {
450+ beforeIDs := LeafIDs(tt.tree.Root)
451+ requireUniquePaneIDs(t, beforeIDs)
452+ before, solved := Solve(tt.tree.Root, tt.area)
453+ require.True(t, solved, "weight fixture must solve before surgery")
454+
455+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
456+ require.NoError(t, err)
457+ require.True(t, changed)
458+ require.Equal(t, tt.target, tt.tree.Focus)
459+
460+ afterIDs := LeafIDs(tt.tree.Root)
461+ require.ElementsMatch(t, beforeIDs, afterIDs)
462+ require.Len(t, afterIDs, len(beforeIDs))
463+ requireUniquePaneIDs(t, afterIDs)
464+ after, solved := Solve(tt.tree.Root, tt.area)
465+ require.True(t, solved, "weighted result must solve")
466+ tt.check(t, tt.tree, before, after)
467+ })
468+ }
469+}
470+
471+func childWeights(node *Node) []float64 {
472+ weights := make([]float64, len(node.Children))
473+ for i, child := range node.Children {
474+ weights[i] = child.Weight
475+ }
476+ return weights
477+}
478+
479+func placementSpanWidth(placements []Placement, id PaneID) int {
480+ for _, placement := range placements {
481+ if placement.ID != id {
482+ continue
483+ }
484+ if placement.Content.Width > 0 {
485+ return placement.Content.Width
486+ }
487+ return placement.TitleBar.Width
488+ }
489+ return 0
490+}
491+
326 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {492 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
327 t.Parallel()493 t.Parallel()
328 494

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 2

Tags 13

↑↓ moveenter openesc close