detached ef2479e

feat(command): expose pane consume and expel

commit
ef2479eb95c12a15304867926de3824bc32335f8
author
brice · · 2026-07-27 14:00 UTC
parent
3894536
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +386 -53
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 +9 -0
@@ -409,6 +409,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) }
409409 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
410410 func (e controlExec) SplitUp() error { return e.split(layout.Up) }
411411 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+}
412421 func (e controlExec) StackPane() error {
413422 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
414423 }
@@ -409,6 +409,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) }
409 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }409 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
410 func (e controlExec) SplitUp() error { return e.split(layout.Up) }410 func (e controlExec) SplitUp() error { return e.split(layout.Up) }
411 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+}
412 func (e controlExec) StackPane() error {421 func (e controlExec) StackPane() error {
413 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})422 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
414 }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 +10 -6
@@ -292,7 +292,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
292292 if sess == nil {
293293 return
294294 }
295- runResizeAction := func(request daemonActionRequest) {
295+ runAction := func(request daemonActionRequest) {
296296 request.target = resolveDaemonActionTarget(sess)
297297 runner := h.actions
298298 if runner == nil {
@@ -338,15 +338,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
338338 h.d.reportError(sess, err)
339339 }
340340 case keys.ActionGrowPaneWidth:
341- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
341+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
342342 case keys.ActionShrinkPaneWidth:
343- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
343+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
344344 case keys.ActionGrowPaneHeight:
345- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
345+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
346346 case keys.ActionShrinkPaneHeight:
347- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
347+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
348348 case keys.ActionEqualizePanes:
349- runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes})
349+ runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
350+ case keys.ActionConsumeOrExpelPaneLeft:
351+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
352+ case keys.ActionConsumeOrExpelPaneRight:
353+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
350354 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
351355 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
352356 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
@@ -292,7 +292,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
292 if sess == nil {292 if sess == nil {
293 return293 return
294 }294 }
295- runResizeAction := func(request daemonActionRequest) {295+ runAction := func(request daemonActionRequest) {
296 request.target = resolveDaemonActionTarget(sess)296 request.target = resolveDaemonActionTarget(sess)
297 runner := h.actions297 runner := h.actions
298 if runner == nil {298 if runner == nil {
@@ -338,15 +338,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
338 h.d.reportError(sess, err)338 h.d.reportError(sess, err)
339 }339 }
340 case keys.ActionGrowPaneWidth:340 case keys.ActionGrowPaneWidth:
341- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})341+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
342 case keys.ActionShrinkPaneWidth:342 case keys.ActionShrinkPaneWidth:
343- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})343+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
344 case keys.ActionGrowPaneHeight:344 case keys.ActionGrowPaneHeight:
345- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})345+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
346 case keys.ActionShrinkPaneHeight:346 case keys.ActionShrinkPaneHeight:
347- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})347+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
348 case keys.ActionEqualizePanes:348 case keys.ActionEqualizePanes:
349- runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes})349+ runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
350+ case keys.ActionConsumeOrExpelPaneLeft:
351+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
352+ case keys.ActionConsumeOrExpelPaneRight:
353+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
350 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,354 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
351 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,355 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
352 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:356 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +70 -0
@@ -26,6 +26,76 @@ 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+
2999 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30100 tests := []struct {
31101 name string
@@ -26,6 +26,76 @@ 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+
29 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {99 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30 tests := []struct {100 tests := []struct {
31 name string101 name string
M internal/usecase/daemon/palette.go +9 -0
@@ -407,6 +407,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
407407 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
408408 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
409409 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+}
410419
411420 func (e paletteExec) StackPane() error {
412421 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
@@ -407,6 +407,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
407 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }407 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
408 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }408 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
409 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+}
410 419
411 func (e paletteExec) StackPane() error {420 func (e paletteExec) StackPane() error {
412 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})421 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
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

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close