detached cbd555e

feat(daemon): add pane resize control and modal commands

commit
cbd555e9988ebd63619848ccc88528f2ff63250a
author
brice · · 2026-07-25 04:33 UTC
committer
brice · · 2026-07-25 04:35 UTC
parent
871b49b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +741 -12
M internal/app/cmd_test.go +7 -3
@@ -23,6 +23,8 @@ func TestParseCmdArgs(t *testing.T) {
2323 {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
2424 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
2525 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
26+ {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
27+ {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
2628 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
2729 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
2830 {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
@@ -90,13 +92,15 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
9092 if called {
9193 t.Fatal("help dialed daemon")
9294 }
93- for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} {
95+ 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"} {
9496 if !strings.Contains(out.String(), want) {
9597 t.Fatalf("help %q missing %q", out.String(), want)
9698 }
9799 }
98- if strings.Contains(out.String(), "session-picker") {
99- t.Fatalf("help leaked non-scriptable command: %q", out.String())
100+ for _, hidden := range []string{"session-picker", "resize-pane"} {
101+ if strings.Contains(out.String(), hidden) {
102+ t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String())
103+ }
100104 }
101105 }
102106
@@ -23,6 +23,8 @@ func TestParseCmdArgs(t *testing.T) {
23 {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},23 {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
24 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},24 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
25 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},25 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
26+ {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
27+ {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
26 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},28 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
27 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},29 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
28 {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},30 {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
@@ -90,13 +92,15 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
90 if called {92 if called {
91 t.Fatal("help dialed daemon")93 t.Fatal("help dialed daemon")
92 }94 }
93- for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} {95+ 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"} {
94 if !strings.Contains(out.String(), want) {96 if !strings.Contains(out.String(), want) {
95 t.Fatalf("help %q missing %q", out.String(), want)97 t.Fatalf("help %q missing %q", out.String(), want)
96 }98 }
97 }99 }
98- if strings.Contains(out.String(), "session-picker") {100+ for _, hidden := range []string{"session-picker", "resize-pane"} {
99- t.Fatalf("help leaked non-scriptable command: %q", out.String())101+ if strings.Contains(out.String(), hidden) {
102+ t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String())
103+ }
100 }104 }
101 }105 }
102 106
M internal/usecase/command/command.go +11 -0
@@ -43,6 +43,12 @@ type Context interface {
4343 FocusPaneRight() error
4444 FocusPaneUp() error
4545 FocusPaneDown() error
46+ EnterResizeMode() error
47+ GrowPaneWidth() error
48+ ShrinkPaneWidth() error
49+ GrowPaneHeight() error
50+ ShrinkPaneHeight() error
51+ EqualizePanes() error
4652 NextTab() error
4753 PrevTab() error
4854 BackSession() error
@@ -104,6 +110,11 @@ type ControlContext interface {
104110 SplitDown() error
105111 StackPane() error
106112 ToggleStack() error
113+ GrowPaneWidth() error
114+ ShrinkPaneWidth() error
115+ GrowPaneHeight() error
116+ ShrinkPaneHeight() error
117+ EqualizePanes() error
107118 FocusPaneLeft() error
108119 FocusPaneRight() error
109120 FocusPaneUp() error
@@ -43,6 +43,12 @@ type Context interface {
43 FocusPaneRight() error43 FocusPaneRight() error
44 FocusPaneUp() error44 FocusPaneUp() error
45 FocusPaneDown() error45 FocusPaneDown() error
46+ EnterResizeMode() error
47+ GrowPaneWidth() error
48+ ShrinkPaneWidth() error
49+ GrowPaneHeight() error
50+ ShrinkPaneHeight() error
51+ EqualizePanes() error
46 NextTab() error52 NextTab() error
47 PrevTab() error53 PrevTab() error
48 BackSession() error54 BackSession() error
@@ -104,6 +110,11 @@ type ControlContext interface {
104 SplitDown() error110 SplitDown() error
105 StackPane() error111 StackPane() error
106 ToggleStack() error112 ToggleStack() error
113+ GrowPaneWidth() error
114+ ShrinkPaneWidth() error
115+ GrowPaneHeight() error
116+ ShrinkPaneHeight() error
117+ EqualizePanes() error
107 FocusPaneLeft() error118 FocusPaneLeft() error
108 FocusPaneRight() error119 FocusPaneRight() error
109 FocusPaneUp() error120 FocusPaneUp() error
M internal/usecase/command/context_mock_test.go +264 -0
@@ -299,6 +299,50 @@ func (_c *MockContext_Detach_Call) RunAndReturn(run func() error) *MockContext_D
299299 return _c
300300 }
301301
302+// EnterResizeMode provides a mock function for the type MockContext
303+func (_mock *MockContext) EnterResizeMode() error {
304+ ret := _mock.Called()
305+
306+ if len(ret) == 0 {
307+ panic("no return value specified for EnterResizeMode")
308+ }
309+
310+ var r0 error
311+ if returnFunc, ok := ret.Get(0).(func() error); ok {
312+ r0 = returnFunc()
313+ } else {
314+ r0 = ret.Error(0)
315+ }
316+ return r0
317+}
318+
319+// MockContext_EnterResizeMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnterResizeMode'
320+type MockContext_EnterResizeMode_Call struct {
321+ *mock.Call
322+}
323+
324+// EnterResizeMode is a helper method to define mock.On call
325+func (_e *MockContext_Expecter) EnterResizeMode() *MockContext_EnterResizeMode_Call {
326+ return &MockContext_EnterResizeMode_Call{Call: _e.mock.On("EnterResizeMode")}
327+}
328+
329+func (_c *MockContext_EnterResizeMode_Call) Run(run func()) *MockContext_EnterResizeMode_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ run()
332+ })
333+ return _c
334+}
335+
336+func (_c *MockContext_EnterResizeMode_Call) Return(err error) *MockContext_EnterResizeMode_Call {
337+ _c.Call.Return(err)
338+ return _c
339+}
340+
341+func (_c *MockContext_EnterResizeMode_Call) RunAndReturn(run func() error) *MockContext_EnterResizeMode_Call {
342+ _c.Call.Return(run)
343+ return _c
344+}
345+
302346 // EnterVisualMode provides a mock function for the type MockContext
303347 func (_mock *MockContext) EnterVisualMode() error {
304348 ret := _mock.Called()
@@ -343,6 +387,50 @@ func (_c *MockContext_EnterVisualMode_Call) RunAndReturn(run func() error) *Mock
343387 return _c
344388 }
345389
390+// EqualizePanes provides a mock function for the type MockContext
391+func (_mock *MockContext) EqualizePanes() error {
392+ ret := _mock.Called()
393+
394+ if len(ret) == 0 {
395+ panic("no return value specified for EqualizePanes")
396+ }
397+
398+ var r0 error
399+ if returnFunc, ok := ret.Get(0).(func() error); ok {
400+ r0 = returnFunc()
401+ } else {
402+ r0 = ret.Error(0)
403+ }
404+ return r0
405+}
406+
407+// MockContext_EqualizePanes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EqualizePanes'
408+type MockContext_EqualizePanes_Call struct {
409+ *mock.Call
410+}
411+
412+// EqualizePanes is a helper method to define mock.On call
413+func (_e *MockContext_Expecter) EqualizePanes() *MockContext_EqualizePanes_Call {
414+ return &MockContext_EqualizePanes_Call{Call: _e.mock.On("EqualizePanes")}
415+}
416+
417+func (_c *MockContext_EqualizePanes_Call) Run(run func()) *MockContext_EqualizePanes_Call {
418+ _c.Call.Run(func(args mock.Arguments) {
419+ run()
420+ })
421+ return _c
422+}
423+
424+func (_c *MockContext_EqualizePanes_Call) Return(err error) *MockContext_EqualizePanes_Call {
425+ _c.Call.Return(err)
426+ return _c
427+}
428+
429+func (_c *MockContext_EqualizePanes_Call) RunAndReturn(run func() error) *MockContext_EqualizePanes_Call {
430+ _c.Call.Return(run)
431+ return _c
432+}
433+
346434 // FocusPaneDown provides a mock function for the type MockContext
347435 func (_mock *MockContext) FocusPaneDown() error {
348436 ret := _mock.Called()
@@ -519,6 +607,94 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
519607 return _c
520608 }
521609
610+// GrowPaneHeight provides a mock function for the type MockContext
611+func (_mock *MockContext) GrowPaneHeight() error {
612+ ret := _mock.Called()
613+
614+ if len(ret) == 0 {
615+ panic("no return value specified for GrowPaneHeight")
616+ }
617+
618+ var r0 error
619+ if returnFunc, ok := ret.Get(0).(func() error); ok {
620+ r0 = returnFunc()
621+ } else {
622+ r0 = ret.Error(0)
623+ }
624+ return r0
625+}
626+
627+// MockContext_GrowPaneHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrowPaneHeight'
628+type MockContext_GrowPaneHeight_Call struct {
629+ *mock.Call
630+}
631+
632+// GrowPaneHeight is a helper method to define mock.On call
633+func (_e *MockContext_Expecter) GrowPaneHeight() *MockContext_GrowPaneHeight_Call {
634+ return &MockContext_GrowPaneHeight_Call{Call: _e.mock.On("GrowPaneHeight")}
635+}
636+
637+func (_c *MockContext_GrowPaneHeight_Call) Run(run func()) *MockContext_GrowPaneHeight_Call {
638+ _c.Call.Run(func(args mock.Arguments) {
639+ run()
640+ })
641+ return _c
642+}
643+
644+func (_c *MockContext_GrowPaneHeight_Call) Return(err error) *MockContext_GrowPaneHeight_Call {
645+ _c.Call.Return(err)
646+ return _c
647+}
648+
649+func (_c *MockContext_GrowPaneHeight_Call) RunAndReturn(run func() error) *MockContext_GrowPaneHeight_Call {
650+ _c.Call.Return(run)
651+ return _c
652+}
653+
654+// GrowPaneWidth provides a mock function for the type MockContext
655+func (_mock *MockContext) GrowPaneWidth() error {
656+ ret := _mock.Called()
657+
658+ if len(ret) == 0 {
659+ panic("no return value specified for GrowPaneWidth")
660+ }
661+
662+ var r0 error
663+ if returnFunc, ok := ret.Get(0).(func() error); ok {
664+ r0 = returnFunc()
665+ } else {
666+ r0 = ret.Error(0)
667+ }
668+ return r0
669+}
670+
671+// MockContext_GrowPaneWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrowPaneWidth'
672+type MockContext_GrowPaneWidth_Call struct {
673+ *mock.Call
674+}
675+
676+// GrowPaneWidth is a helper method to define mock.On call
677+func (_e *MockContext_Expecter) GrowPaneWidth() *MockContext_GrowPaneWidth_Call {
678+ return &MockContext_GrowPaneWidth_Call{Call: _e.mock.On("GrowPaneWidth")}
679+}
680+
681+func (_c *MockContext_GrowPaneWidth_Call) Run(run func()) *MockContext_GrowPaneWidth_Call {
682+ _c.Call.Run(func(args mock.Arguments) {
683+ run()
684+ })
685+ return _c
686+}
687+
688+func (_c *MockContext_GrowPaneWidth_Call) Return(err error) *MockContext_GrowPaneWidth_Call {
689+ _c.Call.Return(err)
690+ return _c
691+}
692+
693+func (_c *MockContext_GrowPaneWidth_Call) RunAndReturn(run func() error) *MockContext_GrowPaneWidth_Call {
694+ _c.Call.Return(run)
695+ return _c
696+}
697+
522698 // JumpRecentSession provides a mock function for the type MockContext
523699 func (_mock *MockContext) JumpRecentSession(rank int) error {
524700 ret := _mock.Called(rank)
@@ -834,6 +1010,94 @@ func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContex
8341010 return _c
8351011 }
8361012
1013+// ShrinkPaneHeight provides a mock function for the type MockContext
1014+func (_mock *MockContext) ShrinkPaneHeight() error {
1015+ ret := _mock.Called()
1016+
1017+ if len(ret) == 0 {
1018+ panic("no return value specified for ShrinkPaneHeight")
1019+ }
1020+
1021+ var r0 error
1022+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1023+ r0 = returnFunc()
1024+ } else {
1025+ r0 = ret.Error(0)
1026+ }
1027+ return r0
1028+}
1029+
1030+// MockContext_ShrinkPaneHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShrinkPaneHeight'
1031+type MockContext_ShrinkPaneHeight_Call struct {
1032+ *mock.Call
1033+}
1034+
1035+// ShrinkPaneHeight is a helper method to define mock.On call
1036+func (_e *MockContext_Expecter) ShrinkPaneHeight() *MockContext_ShrinkPaneHeight_Call {
1037+ return &MockContext_ShrinkPaneHeight_Call{Call: _e.mock.On("ShrinkPaneHeight")}
1038+}
1039+
1040+func (_c *MockContext_ShrinkPaneHeight_Call) Run(run func()) *MockContext_ShrinkPaneHeight_Call {
1041+ _c.Call.Run(func(args mock.Arguments) {
1042+ run()
1043+ })
1044+ return _c
1045+}
1046+
1047+func (_c *MockContext_ShrinkPaneHeight_Call) Return(err error) *MockContext_ShrinkPaneHeight_Call {
1048+ _c.Call.Return(err)
1049+ return _c
1050+}
1051+
1052+func (_c *MockContext_ShrinkPaneHeight_Call) RunAndReturn(run func() error) *MockContext_ShrinkPaneHeight_Call {
1053+ _c.Call.Return(run)
1054+ return _c
1055+}
1056+
1057+// ShrinkPaneWidth provides a mock function for the type MockContext
1058+func (_mock *MockContext) ShrinkPaneWidth() error {
1059+ ret := _mock.Called()
1060+
1061+ if len(ret) == 0 {
1062+ panic("no return value specified for ShrinkPaneWidth")
1063+ }
1064+
1065+ var r0 error
1066+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1067+ r0 = returnFunc()
1068+ } else {
1069+ r0 = ret.Error(0)
1070+ }
1071+ return r0
1072+}
1073+
1074+// MockContext_ShrinkPaneWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShrinkPaneWidth'
1075+type MockContext_ShrinkPaneWidth_Call struct {
1076+ *mock.Call
1077+}
1078+
1079+// ShrinkPaneWidth is a helper method to define mock.On call
1080+func (_e *MockContext_Expecter) ShrinkPaneWidth() *MockContext_ShrinkPaneWidth_Call {
1081+ return &MockContext_ShrinkPaneWidth_Call{Call: _e.mock.On("ShrinkPaneWidth")}
1082+}
1083+
1084+func (_c *MockContext_ShrinkPaneWidth_Call) Run(run func()) *MockContext_ShrinkPaneWidth_Call {
1085+ _c.Call.Run(func(args mock.Arguments) {
1086+ run()
1087+ })
1088+ return _c
1089+}
1090+
1091+func (_c *MockContext_ShrinkPaneWidth_Call) Return(err error) *MockContext_ShrinkPaneWidth_Call {
1092+ _c.Call.Return(err)
1093+ return _c
1094+}
1095+
1096+func (_c *MockContext_ShrinkPaneWidth_Call) RunAndReturn(run func() error) *MockContext_ShrinkPaneWidth_Call {
1097+ _c.Call.Return(run)
1098+ return _c
1099+}
1100+
8371101 // SplitDown provides a mock function for the type MockContext
8381102 func (_mock *MockContext) SplitDown() error {
8391103 ret := _mock.Called()
@@ -299,6 +299,50 @@ func (_c *MockContext_Detach_Call) RunAndReturn(run func() error) *MockContext_D
299 return _c299 return _c
300 }300 }
301 301
302+// EnterResizeMode provides a mock function for the type MockContext
303+func (_mock *MockContext) EnterResizeMode() error {
304+ ret := _mock.Called()
305+
306+ if len(ret) == 0 {
307+ panic("no return value specified for EnterResizeMode")
308+ }
309+
310+ var r0 error
311+ if returnFunc, ok := ret.Get(0).(func() error); ok {
312+ r0 = returnFunc()
313+ } else {
314+ r0 = ret.Error(0)
315+ }
316+ return r0
317+}
318+
319+// MockContext_EnterResizeMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnterResizeMode'
320+type MockContext_EnterResizeMode_Call struct {
321+ *mock.Call
322+}
323+
324+// EnterResizeMode is a helper method to define mock.On call
325+func (_e *MockContext_Expecter) EnterResizeMode() *MockContext_EnterResizeMode_Call {
326+ return &MockContext_EnterResizeMode_Call{Call: _e.mock.On("EnterResizeMode")}
327+}
328+
329+func (_c *MockContext_EnterResizeMode_Call) Run(run func()) *MockContext_EnterResizeMode_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ run()
332+ })
333+ return _c
334+}
335+
336+func (_c *MockContext_EnterResizeMode_Call) Return(err error) *MockContext_EnterResizeMode_Call {
337+ _c.Call.Return(err)
338+ return _c
339+}
340+
341+func (_c *MockContext_EnterResizeMode_Call) RunAndReturn(run func() error) *MockContext_EnterResizeMode_Call {
342+ _c.Call.Return(run)
343+ return _c
344+}
345+
302 // EnterVisualMode provides a mock function for the type MockContext346 // EnterVisualMode provides a mock function for the type MockContext
303 func (_mock *MockContext) EnterVisualMode() error {347 func (_mock *MockContext) EnterVisualMode() error {
304 ret := _mock.Called()348 ret := _mock.Called()
@@ -343,6 +387,50 @@ func (_c *MockContext_EnterVisualMode_Call) RunAndReturn(run func() error) *Mock
343 return _c387 return _c
344 }388 }
345 389
390+// EqualizePanes provides a mock function for the type MockContext
391+func (_mock *MockContext) EqualizePanes() error {
392+ ret := _mock.Called()
393+
394+ if len(ret) == 0 {
395+ panic("no return value specified for EqualizePanes")
396+ }
397+
398+ var r0 error
399+ if returnFunc, ok := ret.Get(0).(func() error); ok {
400+ r0 = returnFunc()
401+ } else {
402+ r0 = ret.Error(0)
403+ }
404+ return r0
405+}
406+
407+// MockContext_EqualizePanes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EqualizePanes'
408+type MockContext_EqualizePanes_Call struct {
409+ *mock.Call
410+}
411+
412+// EqualizePanes is a helper method to define mock.On call
413+func (_e *MockContext_Expecter) EqualizePanes() *MockContext_EqualizePanes_Call {
414+ return &MockContext_EqualizePanes_Call{Call: _e.mock.On("EqualizePanes")}
415+}
416+
417+func (_c *MockContext_EqualizePanes_Call) Run(run func()) *MockContext_EqualizePanes_Call {
418+ _c.Call.Run(func(args mock.Arguments) {
419+ run()
420+ })
421+ return _c
422+}
423+
424+func (_c *MockContext_EqualizePanes_Call) Return(err error) *MockContext_EqualizePanes_Call {
425+ _c.Call.Return(err)
426+ return _c
427+}
428+
429+func (_c *MockContext_EqualizePanes_Call) RunAndReturn(run func() error) *MockContext_EqualizePanes_Call {
430+ _c.Call.Return(run)
431+ return _c
432+}
433+
346 // FocusPaneDown provides a mock function for the type MockContext434 // FocusPaneDown provides a mock function for the type MockContext
347 func (_mock *MockContext) FocusPaneDown() error {435 func (_mock *MockContext) FocusPaneDown() error {
348 ret := _mock.Called()436 ret := _mock.Called()
@@ -519,6 +607,94 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
519 return _c607 return _c
520 }608 }
521 609
610+// GrowPaneHeight provides a mock function for the type MockContext
611+func (_mock *MockContext) GrowPaneHeight() error {
612+ ret := _mock.Called()
613+
614+ if len(ret) == 0 {
615+ panic("no return value specified for GrowPaneHeight")
616+ }
617+
618+ var r0 error
619+ if returnFunc, ok := ret.Get(0).(func() error); ok {
620+ r0 = returnFunc()
621+ } else {
622+ r0 = ret.Error(0)
623+ }
624+ return r0
625+}
626+
627+// MockContext_GrowPaneHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrowPaneHeight'
628+type MockContext_GrowPaneHeight_Call struct {
629+ *mock.Call
630+}
631+
632+// GrowPaneHeight is a helper method to define mock.On call
633+func (_e *MockContext_Expecter) GrowPaneHeight() *MockContext_GrowPaneHeight_Call {
634+ return &MockContext_GrowPaneHeight_Call{Call: _e.mock.On("GrowPaneHeight")}
635+}
636+
637+func (_c *MockContext_GrowPaneHeight_Call) Run(run func()) *MockContext_GrowPaneHeight_Call {
638+ _c.Call.Run(func(args mock.Arguments) {
639+ run()
640+ })
641+ return _c
642+}
643+
644+func (_c *MockContext_GrowPaneHeight_Call) Return(err error) *MockContext_GrowPaneHeight_Call {
645+ _c.Call.Return(err)
646+ return _c
647+}
648+
649+func (_c *MockContext_GrowPaneHeight_Call) RunAndReturn(run func() error) *MockContext_GrowPaneHeight_Call {
650+ _c.Call.Return(run)
651+ return _c
652+}
653+
654+// GrowPaneWidth provides a mock function for the type MockContext
655+func (_mock *MockContext) GrowPaneWidth() error {
656+ ret := _mock.Called()
657+
658+ if len(ret) == 0 {
659+ panic("no return value specified for GrowPaneWidth")
660+ }
661+
662+ var r0 error
663+ if returnFunc, ok := ret.Get(0).(func() error); ok {
664+ r0 = returnFunc()
665+ } else {
666+ r0 = ret.Error(0)
667+ }
668+ return r0
669+}
670+
671+// MockContext_GrowPaneWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrowPaneWidth'
672+type MockContext_GrowPaneWidth_Call struct {
673+ *mock.Call
674+}
675+
676+// GrowPaneWidth is a helper method to define mock.On call
677+func (_e *MockContext_Expecter) GrowPaneWidth() *MockContext_GrowPaneWidth_Call {
678+ return &MockContext_GrowPaneWidth_Call{Call: _e.mock.On("GrowPaneWidth")}
679+}
680+
681+func (_c *MockContext_GrowPaneWidth_Call) Run(run func()) *MockContext_GrowPaneWidth_Call {
682+ _c.Call.Run(func(args mock.Arguments) {
683+ run()
684+ })
685+ return _c
686+}
687+
688+func (_c *MockContext_GrowPaneWidth_Call) Return(err error) *MockContext_GrowPaneWidth_Call {
689+ _c.Call.Return(err)
690+ return _c
691+}
692+
693+func (_c *MockContext_GrowPaneWidth_Call) RunAndReturn(run func() error) *MockContext_GrowPaneWidth_Call {
694+ _c.Call.Return(run)
695+ return _c
696+}
697+
522 // JumpRecentSession provides a mock function for the type MockContext698 // JumpRecentSession provides a mock function for the type MockContext
523 func (_mock *MockContext) JumpRecentSession(rank int) error {699 func (_mock *MockContext) JumpRecentSession(rank int) error {
524 ret := _mock.Called(rank)700 ret := _mock.Called(rank)
@@ -834,6 +1010,94 @@ func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContex
834 return _c1010 return _c
835 }1011 }
836 1012
1013+// ShrinkPaneHeight provides a mock function for the type MockContext
1014+func (_mock *MockContext) ShrinkPaneHeight() error {
1015+ ret := _mock.Called()
1016+
1017+ if len(ret) == 0 {
1018+ panic("no return value specified for ShrinkPaneHeight")
1019+ }
1020+
1021+ var r0 error
1022+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1023+ r0 = returnFunc()
1024+ } else {
1025+ r0 = ret.Error(0)
1026+ }
1027+ return r0
1028+}
1029+
1030+// MockContext_ShrinkPaneHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShrinkPaneHeight'
1031+type MockContext_ShrinkPaneHeight_Call struct {
1032+ *mock.Call
1033+}
1034+
1035+// ShrinkPaneHeight is a helper method to define mock.On call
1036+func (_e *MockContext_Expecter) ShrinkPaneHeight() *MockContext_ShrinkPaneHeight_Call {
1037+ return &MockContext_ShrinkPaneHeight_Call{Call: _e.mock.On("ShrinkPaneHeight")}
1038+}
1039+
1040+func (_c *MockContext_ShrinkPaneHeight_Call) Run(run func()) *MockContext_ShrinkPaneHeight_Call {
1041+ _c.Call.Run(func(args mock.Arguments) {
1042+ run()
1043+ })
1044+ return _c
1045+}
1046+
1047+func (_c *MockContext_ShrinkPaneHeight_Call) Return(err error) *MockContext_ShrinkPaneHeight_Call {
1048+ _c.Call.Return(err)
1049+ return _c
1050+}
1051+
1052+func (_c *MockContext_ShrinkPaneHeight_Call) RunAndReturn(run func() error) *MockContext_ShrinkPaneHeight_Call {
1053+ _c.Call.Return(run)
1054+ return _c
1055+}
1056+
1057+// ShrinkPaneWidth provides a mock function for the type MockContext
1058+func (_mock *MockContext) ShrinkPaneWidth() error {
1059+ ret := _mock.Called()
1060+
1061+ if len(ret) == 0 {
1062+ panic("no return value specified for ShrinkPaneWidth")
1063+ }
1064+
1065+ var r0 error
1066+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1067+ r0 = returnFunc()
1068+ } else {
1069+ r0 = ret.Error(0)
1070+ }
1071+ return r0
1072+}
1073+
1074+// MockContext_ShrinkPaneWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShrinkPaneWidth'
1075+type MockContext_ShrinkPaneWidth_Call struct {
1076+ *mock.Call
1077+}
1078+
1079+// ShrinkPaneWidth is a helper method to define mock.On call
1080+func (_e *MockContext_Expecter) ShrinkPaneWidth() *MockContext_ShrinkPaneWidth_Call {
1081+ return &MockContext_ShrinkPaneWidth_Call{Call: _e.mock.On("ShrinkPaneWidth")}
1082+}
1083+
1084+func (_c *MockContext_ShrinkPaneWidth_Call) Run(run func()) *MockContext_ShrinkPaneWidth_Call {
1085+ _c.Call.Run(func(args mock.Arguments) {
1086+ run()
1087+ })
1088+ return _c
1089+}
1090+
1091+func (_c *MockContext_ShrinkPaneWidth_Call) Return(err error) *MockContext_ShrinkPaneWidth_Call {
1092+ _c.Call.Return(err)
1093+ return _c
1094+}
1095+
1096+func (_c *MockContext_ShrinkPaneWidth_Call) RunAndReturn(run func() error) *MockContext_ShrinkPaneWidth_Call {
1097+ _c.Call.Return(run)
1098+ return _c
1099+}
1100+
837 // SplitDown provides a mock function for the type MockContext1101 // SplitDown provides a mock function for the type MockContext
838 func (_mock *MockContext) SplitDown() error {1102 func (_mock *MockContext) SplitDown() error {
839 ret := _mock.Called()1103 ret := _mock.Called()
M internal/usecase/command/registry.go +6 -0
@@ -20,6 +20,12 @@ func Registry() []Command {
2020 paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }),
2121 paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }),
2222 paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }),
23+ paletteOnly("resize-pane", "RSZ", "Resize pane", "Enter pane resize mode", func(ctx Context, _ []string) error { return ctx.EnterResizeMode() }),
24+ paletteControl("grow-pane-width", "GPW", "Grow pane width", "Grow the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneWidth() }, func(ctx ControlContext) error { return ctx.GrowPaneWidth() }),
25+ paletteControl("shrink-pane-width", "SPW", "Shrink pane width", "Shrink the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneWidth() }, func(ctx ControlContext) error { return ctx.ShrinkPaneWidth() }),
26+ paletteControl("grow-pane-height", "GPH", "Grow pane height", "Grow the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneHeight() }, func(ctx ControlContext) error { return ctx.GrowPaneHeight() }),
27+ paletteControl("shrink-pane-height", "SPH", "Shrink pane height", "Shrink the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneHeight() }, func(ctx ControlContext) error { return ctx.ShrinkPaneHeight() }),
28+ paletteControl("equalize-panes", "EQP", "Equalize panes", "Restore equal pane shares in the tab", TargetTab, func(ctx Context, _ []string) error { return ctx.EqualizePanes() }, func(ctx ControlContext) error { return ctx.EqualizePanes() }),
2329 paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }),
2430 paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }),
2531 paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }),
@@ -20,6 +20,12 @@ func Registry() []Command {
20 paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }),20 paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }),
21 paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }),21 paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }),
22 paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }),22 paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }),
23+ paletteOnly("resize-pane", "RSZ", "Resize pane", "Enter pane resize mode", func(ctx Context, _ []string) error { return ctx.EnterResizeMode() }),
24+ paletteControl("grow-pane-width", "GPW", "Grow pane width", "Grow the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneWidth() }, func(ctx ControlContext) error { return ctx.GrowPaneWidth() }),
25+ paletteControl("shrink-pane-width", "SPW", "Shrink pane width", "Shrink the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneWidth() }, func(ctx ControlContext) error { return ctx.ShrinkPaneWidth() }),
26+ paletteControl("grow-pane-height", "GPH", "Grow pane height", "Grow the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneHeight() }, func(ctx ControlContext) error { return ctx.GrowPaneHeight() }),
27+ paletteControl("shrink-pane-height", "SPH", "Shrink pane height", "Shrink the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneHeight() }, func(ctx ControlContext) error { return ctx.ShrinkPaneHeight() }),
28+ paletteControl("equalize-panes", "EQP", "Equalize panes", "Restore equal pane shares in the tab", TargetTab, func(ctx Context, _ []string) error { return ctx.EqualizePanes() }, func(ctx ControlContext) error { return ctx.EqualizePanes() }),
23 paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }),29 paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }),
24 paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }),30 paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }),
25 paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }),31 paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }),
M internal/usecase/command/registry_test.go +17 -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", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
12+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "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))
@@ -68,11 +68,13 @@ func TestRegistryControlMetadata(t *testing.T) {
6868 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
6969 "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab,
7070 "new-session": TargetSession, "toast": TargetSession,
71+ "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane,
72+ "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
7173 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
7274 }
7375 notScriptable := []string{
7476 "session-picker", "notifications", "visual-mode", "yank-last-notification",
75- "detach", "back-session", "jump-recent-session", "toggle-floating-pane",
77+ "detach", "back-session", "jump-recent-session", "toggle-floating-pane", "resize-pane",
7678 }
7779 for slug, target := range scriptable {
7880 cmd, ok := BySlug(slug)
@@ -115,6 +117,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
115117 }{
116118 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
117119 {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},
120+ {name: "resize mutation delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},
121+ {name: "equalize mutation delegates", slug: "equalize-panes", wantCall: "equalize-panes"},
118122 {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"},
119123 {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments},
120124 {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"},
@@ -153,6 +157,11 @@ func (s *controlSpy) SplitUp() error { return s.record("split
153157 func (s *controlSpy) SplitDown() error { return s.record("split-down") }
154158 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
155159 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
160+func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
161+func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") }
162+func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") }
163+func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") }
164+func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") }
156165 func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }
157166 func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }
158167 func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }
@@ -244,6 +253,12 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
244253 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
245254 {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},
246255 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
256+ {code: "RSZ", expect: func(ctx *MockContext) { ctx.EXPECT().EnterResizeMode().Return(nil).Once() }},
257+ {code: "GPW", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneWidth().Return(nil).Once() }},
258+ {code: "SPW", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneWidth().Return(nil).Once() }},
259+ {code: "GPH", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneHeight().Return(nil).Once() }},
260+ {code: "SPH", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneHeight().Return(nil).Once() }},
261+ {code: "EQP", expect: func(ctx *MockContext) { ctx.EXPECT().EqualizePanes().Return(nil).Once() }},
247262 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
248263 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
249264 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().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", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}12+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "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))
@@ -68,11 +68,13 @@ func TestRegistryControlMetadata(t *testing.T) {
68 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,68 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
69 "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab,69 "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab,
70 "new-session": TargetSession, "toast": TargetSession,70 "new-session": TargetSession, "toast": TargetSession,
71+ "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane,
72+ "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
71 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,73 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
72 }74 }
73 notScriptable := []string{75 notScriptable := []string{
74 "session-picker", "notifications", "visual-mode", "yank-last-notification",76 "session-picker", "notifications", "visual-mode", "yank-last-notification",
75- "detach", "back-session", "jump-recent-session", "toggle-floating-pane",77+ "detach", "back-session", "jump-recent-session", "toggle-floating-pane", "resize-pane",
76 }78 }
77 for slug, target := range scriptable {79 for slug, target := range scriptable {
78 cmd, ok := BySlug(slug)80 cmd, ok := BySlug(slug)
@@ -115,6 +117,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
115 }{117 }{
116 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},118 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
117 {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},119 {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},
120+ {name: "resize mutation delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},
121+ {name: "equalize mutation delegates", slug: "equalize-panes", wantCall: "equalize-panes"},
118 {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"},122 {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"},
119 {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments},123 {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments},
120 {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"},124 {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"},
@@ -153,6 +157,11 @@ func (s *controlSpy) SplitUp() error { return s.record("split
153 func (s *controlSpy) SplitDown() error { return s.record("split-down") }157 func (s *controlSpy) SplitDown() error { return s.record("split-down") }
154 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }158 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
155 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }159 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
160+func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
161+func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") }
162+func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") }
163+func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") }
164+func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") }
156 func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }165 func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }
157 func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }166 func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }
158 func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }167 func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }
@@ -244,6 +253,12 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
244 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},253 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
245 {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},254 {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},
246 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},255 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
256+ {code: "RSZ", expect: func(ctx *MockContext) { ctx.EXPECT().EnterResizeMode().Return(nil).Once() }},
257+ {code: "GPW", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneWidth().Return(nil).Once() }},
258+ {code: "SPW", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneWidth().Return(nil).Once() }},
259+ {code: "GPH", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneHeight().Return(nil).Once() }},
260+ {code: "SPH", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneHeight().Return(nil).Once() }},
261+ {code: "EQP", expect: func(ctx *MockContext) { ctx.EXPECT().EqualizePanes().Return(nil).Once() }},
247 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},262 {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
248 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},263 {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
249 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},264 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
M internal/usecase/daemon/control.go +22 -0
@@ -70,6 +70,10 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports
7070 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
7171 case errors.Is(err, errSessionNameInUse):
7272 return commandFailure(ports.ErrNameTaken, err.Error())
73+ case errors.Is(err, layout.ErrNotInSplit):
74+ return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split")
75+ case errors.Is(err, layout.ErrTooSmall):
76+ return commandFailure(ports.ErrNoSuchTarget, "pane cannot be resized further")
7377 default:
7478 return commandFailure(ports.ErrInternal, err.Error())
7579 }
@@ -198,6 +202,8 @@ const (
198202 daemonActionPreviousTab
199203 daemonActionRenameSession
200204 daemonActionRenameTab
205+ daemonActionResizePane
206+ daemonActionEqualizePanes
201207 )
202208
203209 type daemonActionTarget struct {
@@ -210,6 +216,8 @@ type daemonActionRequest struct {
210216 kind daemonActionKind
211217 target daemonActionTarget
212218 direction layout.Direction
219+ axis layout.Axis
220+ delta int
213221 viewport domain.Size
214222 name string
215223 }
@@ -274,6 +282,10 @@ func (a daemonActions) Run(request daemonActionRequest) error {
274282 return a.d.renameSession(target.session, request.name)
275283 case daemonActionRenameTab:
276284 return a.d.renameTab(target.session, target.tab, request.name)
285+ case daemonActionResizePane:
286+ return a.d.resizePane(target, request.axis, request.delta)
287+ case daemonActionEqualizePanes:
288+ return a.d.equalizePanes(target)
277289 default:
278290 return errors.New("daemon: unknown action")
279291 }
@@ -405,6 +417,16 @@ func (e controlExec) StackPane() error {
405417 func (e controlExec) ToggleStack() error {
406418 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
407419 }
420+func (e controlExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
421+func (e controlExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
422+func (e controlExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
423+func (e controlExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
424+func (e controlExec) resize(axis layout.Axis, delta int) error {
425+ return e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})
426+}
427+func (e controlExec) EqualizePanes() error {
428+ return e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
429+}
408430 func (e controlExec) focus(direction layout.Direction) error {
409431 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
410432 }
@@ -70,6 +70,10 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports
70 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)70 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
71 case errors.Is(err, errSessionNameInUse):71 case errors.Is(err, errSessionNameInUse):
72 return commandFailure(ports.ErrNameTaken, err.Error())72 return commandFailure(ports.ErrNameTaken, err.Error())
73+ case errors.Is(err, layout.ErrNotInSplit):
74+ return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split")
75+ case errors.Is(err, layout.ErrTooSmall):
76+ return commandFailure(ports.ErrNoSuchTarget, "pane cannot be resized further")
73 default:77 default:
74 return commandFailure(ports.ErrInternal, err.Error())78 return commandFailure(ports.ErrInternal, err.Error())
75 }79 }
@@ -198,6 +202,8 @@ const (
198 daemonActionPreviousTab202 daemonActionPreviousTab
199 daemonActionRenameSession203 daemonActionRenameSession
200 daemonActionRenameTab204 daemonActionRenameTab
205+ daemonActionResizePane
206+ daemonActionEqualizePanes
201 )207 )
202 208
203 type daemonActionTarget struct {209 type daemonActionTarget struct {
@@ -210,6 +216,8 @@ type daemonActionRequest struct {
210 kind daemonActionKind216 kind daemonActionKind
211 target daemonActionTarget217 target daemonActionTarget
212 direction layout.Direction218 direction layout.Direction
219+ axis layout.Axis
220+ delta int
213 viewport domain.Size221 viewport domain.Size
214 name string222 name string
215 }223 }
@@ -274,6 +282,10 @@ func (a daemonActions) Run(request daemonActionRequest) error {
274 return a.d.renameSession(target.session, request.name)282 return a.d.renameSession(target.session, request.name)
275 case daemonActionRenameTab:283 case daemonActionRenameTab:
276 return a.d.renameTab(target.session, target.tab, request.name)284 return a.d.renameTab(target.session, target.tab, request.name)
285+ case daemonActionResizePane:
286+ return a.d.resizePane(target, request.axis, request.delta)
287+ case daemonActionEqualizePanes:
288+ return a.d.equalizePanes(target)
277 default:289 default:
278 return errors.New("daemon: unknown action")290 return errors.New("daemon: unknown action")
279 }291 }
@@ -405,6 +417,16 @@ func (e controlExec) StackPane() error {
405 func (e controlExec) ToggleStack() error {417 func (e controlExec) ToggleStack() error {
406 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})418 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
407 }419 }
420+func (e controlExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
421+func (e controlExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
422+func (e controlExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
423+func (e controlExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
424+func (e controlExec) resize(axis layout.Axis, delta int) error {
425+ return e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})
426+}
427+func (e controlExec) EqualizePanes() error {
428+ return e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
429+}
408 func (e controlExec) focus(direction layout.Direction) error {430 func (e controlExec) focus(direction layout.Direction) error {
409 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})431 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
410 }432 }
M internal/usecase/daemon/input.go +1 -1
@@ -36,7 +36,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
3636 frameEvent := ev
3737 ac.initOverlays()
3838 rt := ac.overlays
39- if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() {
39+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() {
4040 return
4141 }
4242 sess := ac.currentSession()
@@ -36,7 +36,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
36 frameEvent := ev36 frameEvent := ev
37 ac.initOverlays()37 ac.initOverlays()
38 rt := ac.overlays38 rt := ac.overlays
39- if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() {39+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() {
40 return40 return
41 }41 }
42 sess := ac.currentSession()42 sess := ac.currentSession()
M internal/usecase/daemon/overlay.go +10 -0
@@ -13,6 +13,8 @@ type overlayEvents struct {
1313 cancel func()
1414 up func()
1515 down func()
16+ left func()
17+ right func()
1618 }
1719
1820 func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
@@ -92,6 +94,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete
9294 call(ev.up)
9395 case 'B':
9496 call(ev.down)
97+ case 'C':
98+ call(ev.right)
99+ case 'D':
100+ call(ev.left)
95101 }
96102 return 3, false
97103 case '[':
@@ -105,6 +111,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete
105111 call(ev.up)
106112 case 'B':
107113 call(ev.down)
114+ case 'C':
115+ call(ev.right)
116+ case 'D':
117+ call(ev.left)
108118 }
109119 }
110120 return i + 1, false
@@ -13,6 +13,8 @@ type overlayEvents struct {
13 cancel func()13 cancel func()
14 up func()14 up func()
15 down func()15 down func()
16+ left func()
17+ right func()
16 }18 }
17 19
18 func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {20 func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) {
@@ -92,6 +94,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete
92 call(ev.up)94 call(ev.up)
93 case 'B':95 case 'B':
94 call(ev.down)96 call(ev.down)
97+ case 'C':
98+ call(ev.right)
99+ case 'D':
100+ call(ev.left)
95 }101 }
96 return 3, false102 return 3, false
97 case '[':103 case '[':
@@ -105,6 +111,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete
105 call(ev.up)111 call(ev.up)
106 case 'B':112 case 'B':
107 call(ev.down)113 call(ev.down)
114+ case 'C':
115+ call(ev.right)
116+ case 'D':
117+ call(ev.left)
108 }118 }
109 }119 }
110 return i + 1, false120 return i + 1, false
M internal/usecase/daemon/overlay_runtime.go +24 -1
@@ -37,6 +37,11 @@ type overlayRuntime struct {
3737 promptSubmit func(string) error
3838 promptPending []byte
3939
40+ resizeMu sync.Mutex
41+ resizeActive bool
42+ resizePending []byte
43+ resizeESC pendingByteTimer
44+
4045 copyMu sync.Mutex
4146 copyMode *scopy.Mode
4247 copyCandidate *scopy.Mode
@@ -98,7 +103,7 @@ func (rt *overlayRuntime) Active() bool {
98103 if rt == nil || rt.ac == nil {
99104 return false
100105 }
101- return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive()
106+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive()
102107 }
103108
104109 func (rt *overlayRuntime) promptActive() bool {
@@ -137,6 +142,15 @@ func (rt *overlayRuntime) noticesActive() bool {
137142 return rt.noticesOverlay != nil
138143 }
139144
145+func (rt *overlayRuntime) resizeModeActive() bool {
146+ if rt == nil {
147+ return false
148+ }
149+ rt.resizeMu.Lock()
150+ defer rt.resizeMu.Unlock()
151+ return rt.resizeActive
152+}
153+
140154 func (rt *overlayRuntime) copyActive() bool {
141155 if rt == nil {
142156 return false
@@ -235,6 +249,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
235249 d.handleNoticesInput(ac, data)
236250 return true
237251 }
252+ if rt.resizeModeActive() {
253+ d.handleResizeInput(ac, data)
254+ return true
255+ }
238256 if rt.copyActive() {
239257 d.handleCopyInput(ac, data)
240258 return true
@@ -250,6 +268,7 @@ type overlayRenderSnapshot struct {
250268 copyPane *pane
251269 copySearchModel *visualsearch.Model
252270 statusFeedback string
271+ resizeActive bool
253272
254273 pickerActive bool
255274 pickerModel *picker.Model
@@ -316,6 +335,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
316335 }
317336 rt.copyMu.Unlock()
318337
338+ rt.resizeMu.Lock()
339+ snap.resizeActive = rt.resizeActive
340+ rt.resizeMu.Unlock()
341+
319342 rt.pickerMu.Lock()
320343 snap.pickerActive = rt.picker != nil
321344 snap.pickerModel = rt.picker.Clone()
@@ -37,6 +37,11 @@ type overlayRuntime struct {
37 promptSubmit func(string) error37 promptSubmit func(string) error
38 promptPending []byte38 promptPending []byte
39 39
40+ resizeMu sync.Mutex
41+ resizeActive bool
42+ resizePending []byte
43+ resizeESC pendingByteTimer
44+
40 copyMu sync.Mutex45 copyMu sync.Mutex
41 copyMode *scopy.Mode46 copyMode *scopy.Mode
42 copyCandidate *scopy.Mode47 copyCandidate *scopy.Mode
@@ -98,7 +103,7 @@ func (rt *overlayRuntime) Active() bool {
98 if rt == nil || rt.ac == nil {103 if rt == nil || rt.ac == nil {
99 return false104 return false
100 }105 }
101- return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive()106+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive()
102 }107 }
103 108
104 func (rt *overlayRuntime) promptActive() bool {109 func (rt *overlayRuntime) promptActive() bool {
@@ -137,6 +142,15 @@ func (rt *overlayRuntime) noticesActive() bool {
137 return rt.noticesOverlay != nil142 return rt.noticesOverlay != nil
138 }143 }
139 144
145+func (rt *overlayRuntime) resizeModeActive() bool {
146+ if rt == nil {
147+ return false
148+ }
149+ rt.resizeMu.Lock()
150+ defer rt.resizeMu.Unlock()
151+ return rt.resizeActive
152+}
153+
140 func (rt *overlayRuntime) copyActive() bool {154 func (rt *overlayRuntime) copyActive() bool {
141 if rt == nil {155 if rt == nil {
142 return false156 return false
@@ -235,6 +249,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
235 d.handleNoticesInput(ac, data)249 d.handleNoticesInput(ac, data)
236 return true250 return true
237 }251 }
252+ if rt.resizeModeActive() {
253+ d.handleResizeInput(ac, data)
254+ return true
255+ }
238 if rt.copyActive() {256 if rt.copyActive() {
239 d.handleCopyInput(ac, data)257 d.handleCopyInput(ac, data)
240 return true258 return true
@@ -250,6 +268,7 @@ type overlayRenderSnapshot struct {
250 copyPane *pane268 copyPane *pane
251 copySearchModel *visualsearch.Model269 copySearchModel *visualsearch.Model
252 statusFeedback string270 statusFeedback string
271+ resizeActive bool
253 272
254 pickerActive bool273 pickerActive bool
255 pickerModel *picker.Model274 pickerModel *picker.Model
@@ -316,6 +335,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
316 }335 }
317 rt.copyMu.Unlock()336 rt.copyMu.Unlock()
318 337
338+ rt.resizeMu.Lock()
339+ snap.resizeActive = rt.resizeActive
340+ rt.resizeMu.Unlock()
341+
319 rt.pickerMu.Lock()342 rt.pickerMu.Lock()
320 snap.pickerActive = rt.picker != nil343 snap.pickerActive = rt.picker != nil
321 snap.pickerModel = rt.picker.Clone()344 snap.pickerModel = rt.picker.Clone()
M internal/usecase/daemon/palette.go +12 -0
@@ -418,6 +418,18 @@ func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
418418 func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }
419419 func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }
420420
421+func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) }
422+func (e paletteExec) resize(axis layout.Axis, delta int) error {
423+ return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}))
424+}
425+func (e paletteExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
426+func (e paletteExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
427+func (e paletteExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
428+func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
429+func (e paletteExec) EqualizePanes() error {
430+ return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
431+}
432+
421433 func (e paletteExec) NextTab() error {
422434 return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
423435 }
@@ -418,6 +418,18 @@ func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
418 func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }418 func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }
419 func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }419 func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }
420 420
421+func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) }
422+func (e paletteExec) resize(axis layout.Axis, delta int) error {
423+ return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}))
424+}
425+func (e paletteExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
426+func (e paletteExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
427+func (e paletteExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
428+func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
429+func (e paletteExec) EqualizePanes() error {
430+ return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
431+}
432+
421 func (e paletteExec) NextTab() error {433 func (e paletteExec) NextTab() error {
422 return e.runAction(daemonActionRequest{kind: daemonActionNextTab})434 return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
423 }435 }
M internal/usecase/daemon/palette_test.go +2 -2
@@ -617,12 +617,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
617617 for i, cmd := range commands {
618618 codes[i] = cmd.Code
619619 }
620- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
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)
621621 }
622622
623623 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
624624 d := &Daemon{}
625- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
625+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
626626
627627 var wg sync.WaitGroup
628628 for range 50 {
@@ -617,12 +617,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
617 for i, cmd := range commands {617 for i, cmd := range commands {
618 codes[i] = cmd.Code618 codes[i] = cmd.Code
619 }619 }
620- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)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)
621 }621 }
622 622
623 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {623 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
624 d := &Daemon{}624 d := &Daemon{}
625- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}625+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
626 626
627 var wg sync.WaitGroup627 var wg sync.WaitGroup
628 for range 50 {628 for range 50 {
M internal/usecase/daemon/render.go +6 -2
@@ -298,7 +298,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
298298 d.refreshSessionFocusedTitles(sess)
299299 // Contextual ranks are completely captured under paletteMu. Compose them
300300 // without reading the live MRU, whose order may have changed mid-interaction.
301- bars := d.barStateForPaletteHints(sess, overlays.statusFeedback, overlays.paletteHints, overlays.paletteRecent)
301+ statusFeedback := overlays.statusFeedback
302+ if statusFeedback == "" && overlays.resizeActive {
303+ statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
304+ }
305+ bars := d.barStateForPaletteHints(sess, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
302306 applied := ac.getAppliedTheme()
303307 bars.theme = applied.Raw
304308 attentionVisible := pulseVisible(bars.attentionFrame)
@@ -327,7 +331,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
327331 capturedOverlays := capturedOverlayRenderState{
328332 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
329333 pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
330- statusFeedback: overlays.statusFeedback,
334+ resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
331335 }
332336 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
333337 state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
@@ -298,7 +298,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
298 d.refreshSessionFocusedTitles(sess)298 d.refreshSessionFocusedTitles(sess)
299 // Contextual ranks are completely captured under paletteMu. Compose them299 // Contextual ranks are completely captured under paletteMu. Compose them
300 // without reading the live MRU, whose order may have changed mid-interaction.300 // without reading the live MRU, whose order may have changed mid-interaction.
301- bars := d.barStateForPaletteHints(sess, overlays.statusFeedback, overlays.paletteHints, overlays.paletteRecent)301+ statusFeedback := overlays.statusFeedback
302+ if statusFeedback == "" && overlays.resizeActive {
303+ statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
304+ }
305+ bars := d.barStateForPaletteHints(sess, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
302 applied := ac.getAppliedTheme()306 applied := ac.getAppliedTheme()
303 bars.theme = applied.Raw307 bars.theme = applied.Raw
304 attentionVisible := pulseVisible(bars.attentionFrame)308 attentionVisible := pulseVisible(bars.attentionFrame)
@@ -327,7 +331,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
327 capturedOverlays := capturedOverlayRenderState{331 capturedOverlays := capturedOverlayRenderState{
328 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,332 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
329 pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,333 pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
330- statusFeedback: overlays.statusFeedback,334+ resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
331 }335 }
332 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)336 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
333 state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{337 state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
M internal/usecase/daemon/render_capture.go +1 -1
@@ -86,7 +86,7 @@ type capturedFloatingRenderState struct {
8686
8787 type capturedOverlayRenderState struct {
8888 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
89- noticesOverlayActive bool
89+ noticesOverlayActive, resizeActive bool
9090 copyMode *scopy.Mode
9191 copyPaneID layout.PaneID
9292 statusFeedback string
@@ -86,7 +86,7 @@ type capturedFloatingRenderState struct {
86 86
87 type capturedOverlayRenderState struct {87 type capturedOverlayRenderState struct {
88 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool88 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
89- noticesOverlayActive bool89+ noticesOverlayActive, resizeActive bool
90 copyMode *scopy.Mode90 copyMode *scopy.Mode
91 copyPaneID layout.PaneID91 copyPaneID layout.PaneID
92 statusFeedback string92 statusFeedback string
A internal/usecase/daemon/resize_mode.go +215 -0
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/keys"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+const (
13+ resizeStepCols = 2
14+ resizeStepRows = 1
15+)
16+
17+func (d *Daemon) resizePane(target daemonActionTarget, axis layout.Axis, delta int) error {
18+ return d.mutateTargetLayout(target, true, func(candidate *layout.Tree, area domain.Rect) error {
19+ originalFocus := candidate.Focus
20+ candidate.Focus = target.pane.id
21+ if err := candidate.ResizeFocus(axis, delta, area); err != nil {
22+ return err
23+ }
24+ candidate.Focus = originalFocus
25+ return nil
26+ })
27+}
28+
29+func (d *Daemon) equalizePanes(target daemonActionTarget) error {
30+ return d.mutateTargetLayout(target, false, func(candidate *layout.Tree, area domain.Rect) error {
31+ return candidate.Equalize(area)
32+ })
33+}
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 {
84+ switch {
85+ case errors.Is(err, layout.ErrNotInSplit):
86+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane is not in a split", err)
87+ case errors.Is(err, layout.ErrTooSmall):
88+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane cannot be resized further", err)
89+ default:
90+ return err
91+ }
92+}
93+
94+func (d *Daemon) enterResizeMode(sess *session, ac *attachedClient) error {
95+ if sess == nil || ac == nil {
96+ return layout.ErrNotFound
97+ }
98+ target := resolveDaemonActionTarget(sess)
99+ if target.tab == nil || target.pane == nil {
100+ return layout.ErrNotFound
101+ }
102+ target.tab.mu.Lock()
103+ canResize := target.tab.tree != nil && target.tab.tree.CanResize()
104+ target.tab.mu.Unlock()
105+ if !canResize {
106+ return resizeUserError(layout.ErrNotInSplit)
107+ }
108+ d.exitCopyMode(ac)
109+ ac.initOverlays()
110+ ac.overlays.resizeMu.Lock()
111+ ac.overlays.resizeESC.stop()
112+ ac.overlays.resizeActive = true
113+ ac.overlays.resizePending = nil
114+ ac.overlays.resizeMu.Unlock()
115+ d.invalidateRender(sess, ac, true, "resize_mode.go")
116+ return nil
117+}
118+
119+func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
120+ if ac == nil || ac.overlays == nil {
121+ return
122+ }
123+ rt := ac.overlays
124+ var requests []daemonActionRequest
125+ exit := false
126+ rt.resizeMu.Lock()
127+ if !rt.resizeActive {
128+ rt.resizePending = nil
129+ rt.resizeESC.stop()
130+ rt.resizeMu.Unlock()
131+ return
132+ }
133+ hadPending := len(rt.resizePending) != 0
134+ if rt.resizeESC.timer != nil {
135+ rt.resizeESC.stop()
136+ }
137+ retainEscape := !hadPending && len(data) != 0 && data[len(data)-1] == keys.ESC
138+ if retainEscape {
139+ data = data[:len(data)-1]
140+ }
141+ appendResize := func(axis layout.Axis, delta int) {
142+ requests = append(requests, daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})
143+ }
144+ routeOverlayBytes(data, &rt.resizePending, overlayEvents{
145+ rune: func(r rune) {
146+ switch r {
147+ case 'h':
148+ appendResize(layout.Width, -resizeStepCols)
149+ case 'l':
150+ appendResize(layout.Width, resizeStepCols)
151+ case 'k':
152+ appendResize(layout.Height, -resizeStepRows)
153+ case 'j':
154+ appendResize(layout.Height, resizeStepRows)
155+ case '=':
156+ requests = append(requests, daemonActionRequest{kind: daemonActionEqualizePanes})
157+ case 'q':
158+ exit = true
159+ }
160+ },
161+ left: func() { appendResize(layout.Width, -resizeStepCols) },
162+ right: func() { appendResize(layout.Width, resizeStepCols) },
163+ up: func() { appendResize(layout.Height, -resizeStepRows) },
164+ down: func() { appendResize(layout.Height, resizeStepRows) },
165+ cancel: func() { exit = true },
166+ enter: func() { exit = true },
167+ })
168+ if exit {
169+ rt.resizeActive = false
170+ rt.resizePending = nil
171+ rt.resizeESC.stop()
172+ } else if retainEscape {
173+ d.retainResizeESCLocked(ac)
174+ }
175+ rt.resizeMu.Unlock()
176+
177+ sess := ac.currentSession()
178+ if sess == nil {
179+ return
180+ }
181+ for _, request := range requests {
182+ request.target = resolveDaemonActionTarget(sess)
183+ if err := (daemonActions{d: d}).Run(request); err != nil {
184+ d.reportError(sess, resizeUserError(err))
185+ continue
186+ }
187+ finishDaemonActionForClient(d, request, ac, "resize_mode.go")
188+ }
189+ if exit {
190+ d.invalidateRender(sess, ac, true, "resize_mode.go")
191+ }
192+}
193+
194+func (d *Daemon) retainResizeESCLocked(ac *attachedClient) {
195+ rt := ac.overlays
196+ rt.resizePending = append(rt.resizePending[:0], keys.ESC)
197+ rt.resizeESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
198+ rt.resizeMu.Lock()
199+ if rt.resizeESC.timer != timer {
200+ rt.resizeMu.Unlock()
201+ return
202+ }
203+ rt.resizePending = nil
204+ rt.resizeESC.timer = nil
205+ rt.resizeESC.done = nil
206+ active := rt.resizeActive
207+ rt.resizeActive = false
208+ rt.resizeMu.Unlock()
209+ if active {
210+ if sess := ac.currentSession(); sess != nil {
211+ d.invalidateRender(sess, ac, true, "resize_mode.go")
212+ }
213+ }
214+ })
215+}
@@ -0,0 +1,215 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/keys"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+const (
13+ resizeStepCols = 2
14+ resizeStepRows = 1
15+)
16+
17+func (d *Daemon) resizePane(target daemonActionTarget, axis layout.Axis, delta int) error {
18+ return d.mutateTargetLayout(target, true, func(candidate *layout.Tree, area domain.Rect) error {
19+ originalFocus := candidate.Focus
20+ candidate.Focus = target.pane.id
21+ if err := candidate.ResizeFocus(axis, delta, area); err != nil {
22+ return err
23+ }
24+ candidate.Focus = originalFocus
25+ return nil
26+ })
27+}
28+
29+func (d *Daemon) equalizePanes(target daemonActionTarget) error {
30+ return d.mutateTargetLayout(target, false, func(candidate *layout.Tree, area domain.Rect) error {
31+ return candidate.Equalize(area)
32+ })
33+}
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 {
84+ switch {
85+ case errors.Is(err, layout.ErrNotInSplit):
86+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane is not in a split", err)
87+ case errors.Is(err, layout.ErrTooSmall):
88+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane cannot be resized further", err)
89+ default:
90+ return err
91+ }
92+}
93+
94+func (d *Daemon) enterResizeMode(sess *session, ac *attachedClient) error {
95+ if sess == nil || ac == nil {
96+ return layout.ErrNotFound
97+ }
98+ target := resolveDaemonActionTarget(sess)
99+ if target.tab == nil || target.pane == nil {
100+ return layout.ErrNotFound
101+ }
102+ target.tab.mu.Lock()
103+ canResize := target.tab.tree != nil && target.tab.tree.CanResize()
104+ target.tab.mu.Unlock()
105+ if !canResize {
106+ return resizeUserError(layout.ErrNotInSplit)
107+ }
108+ d.exitCopyMode(ac)
109+ ac.initOverlays()
110+ ac.overlays.resizeMu.Lock()
111+ ac.overlays.resizeESC.stop()
112+ ac.overlays.resizeActive = true
113+ ac.overlays.resizePending = nil
114+ ac.overlays.resizeMu.Unlock()
115+ d.invalidateRender(sess, ac, true, "resize_mode.go")
116+ return nil
117+}
118+
119+func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
120+ if ac == nil || ac.overlays == nil {
121+ return
122+ }
123+ rt := ac.overlays
124+ var requests []daemonActionRequest
125+ exit := false
126+ rt.resizeMu.Lock()
127+ if !rt.resizeActive {
128+ rt.resizePending = nil
129+ rt.resizeESC.stop()
130+ rt.resizeMu.Unlock()
131+ return
132+ }
133+ hadPending := len(rt.resizePending) != 0
134+ if rt.resizeESC.timer != nil {
135+ rt.resizeESC.stop()
136+ }
137+ retainEscape := !hadPending && len(data) != 0 && data[len(data)-1] == keys.ESC
138+ if retainEscape {
139+ data = data[:len(data)-1]
140+ }
141+ appendResize := func(axis layout.Axis, delta int) {
142+ requests = append(requests, daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})
143+ }
144+ routeOverlayBytes(data, &rt.resizePending, overlayEvents{
145+ rune: func(r rune) {
146+ switch r {
147+ case 'h':
148+ appendResize(layout.Width, -resizeStepCols)
149+ case 'l':
150+ appendResize(layout.Width, resizeStepCols)
151+ case 'k':
152+ appendResize(layout.Height, -resizeStepRows)
153+ case 'j':
154+ appendResize(layout.Height, resizeStepRows)
155+ case '=':
156+ requests = append(requests, daemonActionRequest{kind: daemonActionEqualizePanes})
157+ case 'q':
158+ exit = true
159+ }
160+ },
161+ left: func() { appendResize(layout.Width, -resizeStepCols) },
162+ right: func() { appendResize(layout.Width, resizeStepCols) },
163+ up: func() { appendResize(layout.Height, -resizeStepRows) },
164+ down: func() { appendResize(layout.Height, resizeStepRows) },
165+ cancel: func() { exit = true },
166+ enter: func() { exit = true },
167+ })
168+ if exit {
169+ rt.resizeActive = false
170+ rt.resizePending = nil
171+ rt.resizeESC.stop()
172+ } else if retainEscape {
173+ d.retainResizeESCLocked(ac)
174+ }
175+ rt.resizeMu.Unlock()
176+
177+ sess := ac.currentSession()
178+ if sess == nil {
179+ return
180+ }
181+ for _, request := range requests {
182+ request.target = resolveDaemonActionTarget(sess)
183+ if err := (daemonActions{d: d}).Run(request); err != nil {
184+ d.reportError(sess, resizeUserError(err))
185+ continue
186+ }
187+ finishDaemonActionForClient(d, request, ac, "resize_mode.go")
188+ }
189+ if exit {
190+ d.invalidateRender(sess, ac, true, "resize_mode.go")
191+ }
192+}
193+
194+func (d *Daemon) retainResizeESCLocked(ac *attachedClient) {
195+ rt := ac.overlays
196+ rt.resizePending = append(rt.resizePending[:0], keys.ESC)
197+ rt.resizeESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
198+ rt.resizeMu.Lock()
199+ if rt.resizeESC.timer != timer {
200+ rt.resizeMu.Unlock()
201+ return
202+ }
203+ rt.resizePending = nil
204+ rt.resizeESC.timer = nil
205+ rt.resizeESC.done = nil
206+ active := rt.resizeActive
207+ rt.resizeActive = false
208+ rt.resizeMu.Unlock()
209+ if active {
210+ if sess := ac.currentSession(); sess != nil {
211+ d.invalidateRender(sess, ac, true, "resize_mode.go")
212+ }
213+ }
214+ })
215+}
A internal/usecase/daemon/resize_mode_test.go +143 -0
@@ -0,0 +1,143 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/mouse"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestPaletteAndControlShareResizeAction(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ palette func(paletteExec) error
18+ control func(controlExec) error
19+ kind daemonActionKind
20+ axis layout.Axis
21+ delta int
22+ }{
23+ {"grow width", func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }, daemonActionResizePane, layout.Width, 2},
24+ {"shrink width", func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }, daemonActionResizePane, layout.Width, -2},
25+ {"grow height", func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }, daemonActionResizePane, layout.Height, 1},
26+ {"shrink height", func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }, daemonActionResizePane, layout.Height, -1},
27+ {"equalize", func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }, daemonActionEqualizePanes, layout.Width, 0},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ p := &actionRunnerSpy{}
32+ c := &actionRunnerSpy{}
33+ require.NoError(t, tt.palette(paletteExec{actions: p}))
34+ require.NoError(t, tt.control(controlExec{actions: c}))
35+ require.Len(t, p.requests, 1)
36+ require.Equal(t, p.requests, c.requests)
37+ require.Equal(t, tt.kind, p.requests[0].kind)
38+ require.Equal(t, tt.axis, p.requests[0].axis)
39+ require.Equal(t, tt.delta, p.requests[0].delta)
40+ })
41+ }
42+}
43+
44+func TestResizeControlHeadlessAndErrors(t *testing.T) {
45+ factory := &controlPTYFactory{}
46+ d := newTestDaemon(t, factory, stubClock{})
47+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
48+ sess := addControlSession(d, "work", "t_work", "p_work")
49+
50+ tooEarly := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
51+ require.False(t, tooEarly.OK)
52+ require.Equal(t, ports.ErrNoSuchTarget, tooEarly.Code)
53+ require.Equal(t, "pane is not in a split", tooEarly.Text)
54+
55+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
56+ tb := sess.activeTab()
57+ tb.mu.Lock()
58+ beforeFocus := tb.tree.Focus
59+ beforeGeneration := tb.layoutGeneration
60+ var target *pane
61+ for id, candidate := range tb.panes {
62+ if id != beforeFocus {
63+ target = candidate
64+ break
65+ }
66+ }
67+ tb.mu.Unlock()
68+ require.NotNil(t, target)
69+
70+ require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
71+ kind: daemonActionResizePane,
72+ target: daemonActionTarget{session: sess, tab: tb, pane: target},
73+ axis: layout.Width,
74+ delta: resizeStepCols,
75+ }))
76+ tb.mu.Lock()
77+ require.Equal(t, beforeFocus, tb.tree.Focus, "exact pane targeting must not refocus the tab")
78+ require.Equal(t, beforeGeneration+1, tb.layoutGeneration)
79+ tb.mu.Unlock()
80+
81+ equalized := sendCommand(t, d, ports.CommandRequest{Slug: "equalize-panes", TargetSession: "work"})
82+ require.True(t, equalized.OK, equalized.Text)
83+ require.Empty(t, equalized.Output)
84+}
85+
86+func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) {
87+ factory := &controlPTYFactory{}
88+ d := newTestDaemon(t, factory, stubClock{})
89+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
90+ sess := addControlSession(d, "work", "t_work", "p_work")
91+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
92+
93+ ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
94+ ac.initOverlays()
95+ ac.setSession(sess)
96+ require.NoError(t, d.enterResizeMode(sess, ac))
97+ require.True(t, ac.overlays.resizeModeActive())
98+ d.handleResizeInput(ac, []byte("\x1b"))
99+ require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix")
100+ d.handleResizeInput(ac, []byte("[D"))
101+ require.True(t, ac.overlays.resizeModeActive())
102+
103+ snapshot := ac.overlays.SnapshotForRender()
104+ require.True(t, snapshot.resizeActive)
105+ snapshot.Unlock()
106+ require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer")
107+
108+ tb := sess.activeTab()
109+ tb.mu.Lock()
110+ focused := tb.focusedPane()
111+ tb.mu.Unlock()
112+ focused.mu.Lock()
113+ cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true})
114+ focused.mu.Unlock()
115+ require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live")
116+
117+ ac.overlays.copyMu.Lock()
118+ ac.overlays.copyPointer.valid = true
119+ ac.overlays.copyMu.Unlock()
120+ d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
121+ ac.overlays.copyMu.Lock()
122+ require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing")
123+ ac.overlays.copyMu.Unlock()
124+
125+ ac.overlays.copyMu.Lock()
126+ ac.overlays.copyMode = &scopy.Mode{}
127+ ac.overlays.copyMu.Unlock()
128+ require.True(t, ac.overlays.HandleInput(d, []byte("q")))
129+ require.False(t, ac.overlays.resizeModeActive())
130+ require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode")
131+}
132+
133+func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
134+ var left, right int
135+ pending := []byte(nil)
136+ events := overlayEvents{left: func() { left++ }, right: func() { right++ }}
137+ routeOverlayBytes([]byte("\x1b["), &pending, events)
138+ require.Equal(t, []byte("\x1b["), pending)
139+ routeOverlayBytes([]byte("D\x1bOC"), &pending, events)
140+ require.Equal(t, 1, left)
141+ require.Equal(t, 1, right)
142+ require.Empty(t, pending)
143+}
@@ -0,0 +1,143 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/mouse"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestPaletteAndControlShareResizeAction(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ palette func(paletteExec) error
18+ control func(controlExec) error
19+ kind daemonActionKind
20+ axis layout.Axis
21+ delta int
22+ }{
23+ {"grow width", func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }, daemonActionResizePane, layout.Width, 2},
24+ {"shrink width", func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }, daemonActionResizePane, layout.Width, -2},
25+ {"grow height", func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }, daemonActionResizePane, layout.Height, 1},
26+ {"shrink height", func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }, daemonActionResizePane, layout.Height, -1},
27+ {"equalize", func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }, daemonActionEqualizePanes, layout.Width, 0},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ p := &actionRunnerSpy{}
32+ c := &actionRunnerSpy{}
33+ require.NoError(t, tt.palette(paletteExec{actions: p}))
34+ require.NoError(t, tt.control(controlExec{actions: c}))
35+ require.Len(t, p.requests, 1)
36+ require.Equal(t, p.requests, c.requests)
37+ require.Equal(t, tt.kind, p.requests[0].kind)
38+ require.Equal(t, tt.axis, p.requests[0].axis)
39+ require.Equal(t, tt.delta, p.requests[0].delta)
40+ })
41+ }
42+}
43+
44+func TestResizeControlHeadlessAndErrors(t *testing.T) {
45+ factory := &controlPTYFactory{}
46+ d := newTestDaemon(t, factory, stubClock{})
47+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
48+ sess := addControlSession(d, "work", "t_work", "p_work")
49+
50+ tooEarly := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
51+ require.False(t, tooEarly.OK)
52+ require.Equal(t, ports.ErrNoSuchTarget, tooEarly.Code)
53+ require.Equal(t, "pane is not in a split", tooEarly.Text)
54+
55+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
56+ tb := sess.activeTab()
57+ tb.mu.Lock()
58+ beforeFocus := tb.tree.Focus
59+ beforeGeneration := tb.layoutGeneration
60+ var target *pane
61+ for id, candidate := range tb.panes {
62+ if id != beforeFocus {
63+ target = candidate
64+ break
65+ }
66+ }
67+ tb.mu.Unlock()
68+ require.NotNil(t, target)
69+
70+ require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
71+ kind: daemonActionResizePane,
72+ target: daemonActionTarget{session: sess, tab: tb, pane: target},
73+ axis: layout.Width,
74+ delta: resizeStepCols,
75+ }))
76+ tb.mu.Lock()
77+ require.Equal(t, beforeFocus, tb.tree.Focus, "exact pane targeting must not refocus the tab")
78+ require.Equal(t, beforeGeneration+1, tb.layoutGeneration)
79+ tb.mu.Unlock()
80+
81+ equalized := sendCommand(t, d, ports.CommandRequest{Slug: "equalize-panes", TargetSession: "work"})
82+ require.True(t, equalized.OK, equalized.Text)
83+ require.Empty(t, equalized.Output)
84+}
85+
86+func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) {
87+ factory := &controlPTYFactory{}
88+ d := newTestDaemon(t, factory, stubClock{})
89+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
90+ sess := addControlSession(d, "work", "t_work", "p_work")
91+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
92+
93+ ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
94+ ac.initOverlays()
95+ ac.setSession(sess)
96+ require.NoError(t, d.enterResizeMode(sess, ac))
97+ require.True(t, ac.overlays.resizeModeActive())
98+ d.handleResizeInput(ac, []byte("\x1b"))
99+ require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix")
100+ d.handleResizeInput(ac, []byte("[D"))
101+ require.True(t, ac.overlays.resizeModeActive())
102+
103+ snapshot := ac.overlays.SnapshotForRender()
104+ require.True(t, snapshot.resizeActive)
105+ snapshot.Unlock()
106+ require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer")
107+
108+ tb := sess.activeTab()
109+ tb.mu.Lock()
110+ focused := tb.focusedPane()
111+ tb.mu.Unlock()
112+ focused.mu.Lock()
113+ cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true})
114+ focused.mu.Unlock()
115+ require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live")
116+
117+ ac.overlays.copyMu.Lock()
118+ ac.overlays.copyPointer.valid = true
119+ ac.overlays.copyMu.Unlock()
120+ d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
121+ ac.overlays.copyMu.Lock()
122+ require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing")
123+ ac.overlays.copyMu.Unlock()
124+
125+ ac.overlays.copyMu.Lock()
126+ ac.overlays.copyMode = &scopy.Mode{}
127+ ac.overlays.copyMu.Unlock()
128+ require.True(t, ac.overlays.HandleInput(d, []byte("q")))
129+ require.False(t, ac.overlays.resizeModeActive())
130+ require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode")
131+}
132+
133+func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
134+ var left, right int
135+ pending := []byte(nil)
136+ events := overlayEvents{left: func() { left++ }, right: func() { right++ }}
137+ routeOverlayBytes([]byte("\x1b["), &pending, events)
138+ require.Equal(t, []byte("\x1b["), pending)
139+ routeOverlayBytes([]byte("D\x1bOC"), &pending, events)
140+ require.Equal(t, 1, left)
141+ require.Equal(t, 1, right)
142+ require.Empty(t, pending)
143+}

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