detached 5a69232

Merge pull request #132 from bnema/feat/pane-resize

feat: resize active panes safely
commit
5a6923283b5402d3abbfc84512b18c35b088f5d6
author
bnema · · 2026-07-25 09:23 UTC
committer
GitHub · · 2026-07-25 09:23 UTC
merge of
c41c8a7 + 9519803 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
44 files · +4387 -417
M internal/app/cmd_test.go +41 -3
@@ -10,6 +10,7 @@ import (
10 "testing"10 "testing"
11 11
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
13 )14 )
14 15
15 func TestParseCmdArgs(t *testing.T) {16 func TestParseCmdArgs(t *testing.T) {
@@ -23,6 +24,8 @@ func TestParseCmdArgs(t *testing.T) {
23 {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},24 {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
24 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},25 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
25 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},26 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
27+ {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
28+ {"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},29 {"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},30 {"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},31 {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
@@ -90,13 +93,38 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
90 if called {93 if called {
91 t.Fatal("help dialed daemon")94 t.Fatal("help dialed daemon")
92 }95 }
93- for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} {96+ for _, want := range []string{"usage: vev cmd", "split-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {
94 if !strings.Contains(out.String(), want) {97 if !strings.Contains(out.String(), want) {
95 t.Fatalf("help %q missing %q", out.String(), want)98 t.Fatalf("help %q missing %q", out.String(), want)
96 }99 }
97 }100 }
98- if strings.Contains(out.String(), "session-picker") {101+ for _, hidden := range []string{"session-picker", "resize-pane"} {
99- t.Fatalf("help leaked non-scriptable command: %q", out.String())102+ if strings.Contains(out.String(), hidden) {
103+ t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String())
104+ }
105+ }
106+}
107+
108+func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) {
109+ for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
110+ t.Run(slug, func(t *testing.T) {
111+ invocation, err := parseArgs([]string{"cmd", "--self", slug})
112+ require.NoError(t, err)
113+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
114+ require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
115+ stdout: io.Discard,
116+ getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
117+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
118+ }))
119+ require.Len(t, transport.sent, 1)
120+ request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
121+ require.NoError(t, err)
122+ require.Equal(t, slug, request.Slug)
123+ require.True(t, request.Self)
124+ require.Equal(t, "work", request.TargetSession)
125+ require.Equal(t, "t_abc", request.TargetTab)
126+ require.Equal(t, "p_def", request.TargetPane)
127+ })
100 }128 }
101 }129 }
102 130
@@ -221,6 +249,16 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
221 result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},249 result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},
222 wantCode: 1,250 wantCode: 1,
223 },251 },
252+ {
253+ name: "resize not in split remains an exit one failure",
254+ result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane is not in a split"},
255+ wantCode: 1,
256+ },
257+ {
258+ name: "resize minimum remains an exit one failure",
259+ result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane cannot be resized further"},
260+ wantCode: 1,
261+ },
224 }262 }
225 for _, tt := range tests {263 for _, tt := range tests {
226 t.Run(tt.name, func(t *testing.T) {264 t.Run(tt.name, func(t *testing.T) {
M internal/app/transport_observability_test.go +5 -0
@@ -184,11 +184,15 @@ func TestRunAttachJoinsTraceCloseError(t *testing.T) {
184 return ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil184 return ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil
185 }185 }
186 t.Cleanup(func() { newPerformanceTrace = original })186 t.Cleanup(func() { newPerformanceTrace = original })
187+ t.Setenv("VEV", "")
187 t.Setenv("XDG_STATE_HOME", t.TempDir())188 t.Setenv("XDG_STATE_HOME", t.TempDir())
188 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())189 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
189 t.Setenv(envRemoteTransport, "invalid")190 t.Setenv(envRemoteTransport, "invalid")
190 191
191 err := runAttach(context.Background(), ports.IntentEphemeral, "", "host")192 err := runAttach(context.Background(), ports.IntentEphemeral, "", "host")
193+ if !strings.Contains(err.Error(), "invalid remote transport") {
194+ t.Fatalf("runAttach() error = %v, want remote transport validation error", err)
195+ }
192 if !errors.Is(err, traceCloseErr) {196 if !errors.Is(err, traceCloseErr) {
193 t.Fatalf("runAttach() error = %v, want joined trace close error %v", err, traceCloseErr)197 t.Fatalf("runAttach() error = %v, want joined trace close error %v", err, traceCloseErr)
194 }198 }
@@ -203,6 +207,7 @@ type runtimeObserverFunc func(ports.RuntimeMark)
203 func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) }207 func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) }
204 208
205 func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {209 func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
210+ t.Setenv("VEV", "")
206 t.Setenv("XDG_STATE_HOME", t.TempDir())211 t.Setenv("XDG_STATE_HOME", t.TempDir())
207 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())212 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
208 t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio))213 t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio))
M internal/usecase/command/command.go +11 -0
@@ -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
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 {
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 +22 -2
@@ -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,13 @@ 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: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},
121+ {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"},
122+ {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"},
123+ {name: "shrink height delegates", slug: "shrink-pane-height", wantCall: "shrink-pane-height"},
124+ {name: "equalize delegates", slug: "equalize-panes", wantCall: "equalize-panes"},
125+ {name: "resize mutation rejects arguments", slug: "grow-pane-width", args: []string{"extra"}, wantErr: ErrInvalidArguments},
126+ {name: "equalize rejects arguments", slug: "equalize-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments},
118 {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"},127 {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},128 {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"},129 {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"},
@@ -153,6 +162,11 @@ func (s *controlSpy) SplitUp() error { return s.record("split
153 func (s *controlSpy) SplitDown() error { return s.record("split-down") }162 func (s *controlSpy) SplitDown() error { return s.record("split-down") }
154 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }163 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
155 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }164 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
165+func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
166+func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") }
167+func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") }
168+func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") }
169+func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") }
156 func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }170 func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") }
157 func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }171 func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") }
158 func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }172 func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") }
@@ -244,6 +258,12 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
244 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},258 {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() }},259 {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() }},260 {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
261+ {code: "RSZ", expect: func(ctx *MockContext) { ctx.EXPECT().EnterResizeMode().Return(nil).Once() }},
262+ {code: "GPW", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneWidth().Return(nil).Once() }},
263+ {code: "SPW", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneWidth().Return(nil).Once() }},
264+ {code: "GPH", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneHeight().Return(nil).Once() }},
265+ {code: "SPH", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneHeight().Return(nil).Once() }},
266+ {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() }},267 {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() }},268 {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() }},269 {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
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 }
@@ -275,6 +283,10 @@ func (a daemonActions) Run(request daemonActionRequest) error {
275 return a.d.renameSession(target.session, request.name)283 return a.d.renameSession(target.session, request.name)
276 case daemonActionRenameTab:284 case daemonActionRenameTab:
277 return a.d.renameTab(target.session, target.tab, request.name)285 return a.d.renameTab(target.session, target.tab, request.name)
286+ case daemonActionResizePane:
287+ return a.d.resizePane(target, request.axis, request.delta)
288+ case daemonActionEqualizePanes:
289+ return a.d.equalizePanes(target)
278 default:290 default:
279 return errors.New("daemon: unknown action")291 return errors.New("daemon: unknown action")
280 }292 }
@@ -397,6 +409,16 @@ func (e controlExec) StackPane() error {
397 func (e controlExec) ToggleStack() error {409 func (e controlExec) ToggleStack() error {
398 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})410 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
399 }411 }
412+func (e controlExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
413+func (e controlExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
414+func (e controlExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
415+func (e controlExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
416+func (e controlExec) resize(axis layout.Axis, delta int) error {
417+ return e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})
418+}
419+func (e controlExec) EqualizePanes() error {
420+ return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
421+}
400 func (e controlExec) focus(direction layout.Direction) error {422 func (e controlExec) focus(direction layout.Direction) error {
401 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})423 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
402 }424 }
M internal/usecase/daemon/control_test.go +126 -1
@@ -42,11 +42,12 @@ func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
42 42
43 type actionRunnerSpy struct {43 type actionRunnerSpy struct {
44 requests []daemonActionRequest44 requests []daemonActionRequest
45+ err error
45 }46 }
46 47
47 func (s *actionRunnerSpy) Run(request daemonActionRequest) error {48 func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
48 s.requests = append(s.requests, request)49 s.requests = append(s.requests, request)
49- return nil50+ return s.err
50 }51 }
51 52
52 func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {53 func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
@@ -240,7 +241,131 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
240 active.mu.Unlock()241 active.mu.Unlock()
241 invoking.mu.Lock()242 invoking.mu.Lock()
242 require.Len(t, invoking.panes, 2)243 require.Len(t, invoking.panes, 2)
244+ beforeFocus := invoking.tree.Focus
245+ beforeGeneration := invoking.layoutGeneration
243 invoking.mu.Unlock()246 invoking.mu.Unlock()
247+
248+ grow := sendCommand(t, d, ports.CommandRequest{
249+ Slug: "grow-pane-width", Self: true,
250+ TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
251+ })
252+ require.True(t, grow.OK, grow.Text)
253+ invoking.mu.Lock()
254+ require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab")
255+ require.Equal(t, beforeGeneration+1, invoking.layoutGeneration)
256+ require.NotZero(t, invoking.tree.Root.Children[0].Weight, "resize must update the invoking tab's shares")
257+ invoking.mu.Unlock()
258+ active.mu.Lock()
259+ require.Len(t, active.panes, 1, "--self resize must not mutate the active tab")
260+ active.mu.Unlock()
261+
262+ equalize := sendCommand(t, d, ports.CommandRequest{
263+ Slug: "equalize-panes", Self: true,
264+ TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
265+ })
266+ require.True(t, equalize.OK, equalize.Text)
267+ invoking.mu.Lock()
268+ require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab")
269+ for _, child := range invoking.tree.Root.Children {
270+ require.Zero(t, child.Weight, "equalize must clear each target-tab share")
271+ }
272+ invoking.mu.Unlock()
273+}
274+
275+func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
276+ targets := []struct {
277+ name string
278+ request func() ports.CommandRequest
279+ }{
280+ {"named", func() ports.CommandRequest { return ports.CommandRequest{TargetSession: "work"} }},
281+ {"unique", func() ports.CommandRequest { return ports.CommandRequest{} }},
282+ }
283+ for _, target := range targets {
284+ t.Run(target.name, func(t *testing.T) {
285+ for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
286+ t.Run(slug, func(t *testing.T) {
287+ factory := &controlPTYFactory{}
288+ d := newTestDaemon(t, factory, stubClock{})
289+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
290+ sess := addControlSession(d, "work", "t_work", "p_work")
291+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
292+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
293+ tb := sess.activeTab()
294+ tb.mu.Lock()
295+ generation := tb.layoutGeneration
296+ tb.mu.Unlock()
297+ sess.snapEligible.Store(true)
298+ sess.snapshotMu.Lock()
299+ snapshotGeneration := sess.snapshotGeneration
300+ sess.snapshotMu.Unlock()
301+ request := target.request()
302+ request.Slug = slug
303+ result := sendCommand(t, d, request)
304+ require.True(t, result.OK, result.Text)
305+ require.Empty(t, result.Output)
306+ tb.mu.Lock()
307+ require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
308+ tb.mu.Unlock()
309+ sess.snapshotMu.Lock()
310+ require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
311+ sess.snapshotMu.Unlock()
312+ sess.mu.Lock()
313+ require.Nil(t, sess.client, "one-shots must work headless")
314+ sess.mu.Unlock()
315+ })
316+ }
317+ })
318+ }
319+}
320+
321+func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
322+ factory := &controlPTYFactory{}
323+ d := newTestDaemon(t, factory, stubClock{})
324+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
325+ sess := addControlSession(d, "work", "t_active", "p_active")
326+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
327+ active := sess.tabs[0]
328+ locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
329+ locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
330+ sess.mu.Lock()
331+ sess.tabs = append(sess.tabs, locator)
332+ sess.active = 0
333+ sess.mu.Unlock()
334+ active.mu.Lock()
335+ before := active.layoutGeneration
336+ active.mu.Unlock()
337+
338+ result := sendCommand(t, d, ports.CommandRequest{
339+ Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
340+ })
341+ require.True(t, result.OK, result.Text)
342+ active.mu.Lock()
343+ require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
344+ active.mu.Unlock()
345+ locator.mu.Lock()
346+ require.Zero(t, locator.layoutGeneration)
347+ locator.mu.Unlock()
348+ sess.mu.Lock()
349+ require.Zero(t, sess.active)
350+ sess.mu.Unlock()
351+}
352+
353+func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
354+ factory := &controlPTYFactory{}
355+ d := newTestDaemon(t, factory, stubClock{})
356+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
357+ sess := addControlSession(d, "work", "t_work", "p_work")
358+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
359+ tb := sess.activeTab()
360+ tb.mu.Lock()
361+ tb.size.Cols = 41
362+ tb.bumpLayoutGenerationLocked()
363+ tb.mu.Unlock()
364+
365+ result := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
366+ require.False(t, result.OK)
367+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
368+ require.Equal(t, "pane cannot be resized further", result.Text)
244 }369 }
245 370
246 func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {371 func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
M internal/usecase/daemon/copy_mouse_input.go +5 -2
@@ -268,6 +268,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
268 tb.mu.Lock()268 tb.mu.Lock()
269 geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)269 geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane)
270 otherPane := false270 otherPane := false
271+ layoutChanged := false
271 if ev.Type == mouse.Press {272 if ev.Type == mouse.Press {
272 // A new press is allowed to change focus, but never the document owned273 // A new press is allowed to change focus, but never the document owned
273 // by the active copy mode. Do all tab work first; exitCopyMode takes274 // by the active copy mode. Do all tab work first; exitCopyMode takes
@@ -275,12 +276,14 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta
275 if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {276 if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane {
276 otherPane = true277 otherPane = true
277 if tb.tree != nil {278 if tb.tree != nil {
278- focusPlacementLocked(tb, hit.pane.id)279+ layoutChanged = focusPlacementLocked(tb, hit.pane.id)
279- d.applyLayoutLocked(tb)
280 }280 }
281 }281 }
282 }282 }
283 tb.mu.Unlock()283 tb.mu.Unlock()
284+ if layoutChanged {
285+ d.applyTabLayout(sess, tb)
286+ }
284 if otherPane {287 if otherPane {
285 d.exitCopyMode(ac)288 d.exitCopyMode(ac)
286 return289 return
M internal/usecase/daemon/daemon.go +14 -10
@@ -105,16 +105,20 @@ type Daemon struct {
105 // beforeCopyMouseMap is a deterministic seam after an immutable copy-input105 // beforeCopyMouseMap is a deterministic seam after an immutable copy-input
106 // snapshot is captured and before its mapped position is applied.106 // snapshot is captured and before its mapped position is applied.
107 beforeCopyMouseMap func()107 beforeCopyMouseMap func()
108- ptys ports.PTYFactory108+ // beforeSessionResizePublication runs after final external PTY validation
109- clock ports.Clock109+ // and before coordinator epoch admission. It is a deterministic regression
110- log *slog.Logger110+ // seam for stale resize publication.
111- runtimeObserver ports.RuntimeObserver111+ beforeSessionResizePublication func()
112- baseEnv []string112+ ptys ports.PTYFactory
113- shell string113+ clock ports.Clock
114- shellArgs []string114+ log *slog.Logger
115- shellOverride bool115+ runtimeObserver ports.RuntimeObserver
116- persist *persist.Persister116+ baseEnv []string
117- persistEnabled bool117+ shell string
118+ shellArgs []string
119+ shellOverride bool
120+ persist *persist.Persister
121+ persistEnabled bool
118 // snapshotRepository is the sole checkpoint contract. legacySnapshots is122 // snapshotRepository is the sole checkpoint contract. legacySnapshots is
119 // read-only migration input and is never used for new writes.123 // read-only migration input and is never used for new writes.
120 snapshotRepository ports.SnapshotRepository124 snapshotRepository ports.SnapshotRepository
M internal/usecase/daemon/floating_render_test.go +56 -2
@@ -20,8 +20,8 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr
20 if p == nil || !geometry.valid() {20 if p == nil || !geometry.valid() {
21 return false21 return false
22 }22 }
23- plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}}23+ plan := preparedTabLayout{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}}
24- d.applyResize(&plan)24+ d.applyPreparedTabMembers(&plan)
25 member := plan.members[0]25 member := plan.members[0]
26 if !member.ok {26 if !member.ok {
27 return false27 return false
@@ -31,6 +31,9 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr
31 p.popupGeometry = geometry31 p.popupGeometry = geometry
32 p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)32 p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
33 p.mu.Unlock()33 p.mu.Unlock()
34+ p.resizeMu.Lock()
35+ d.replayResizePending(member.session, member.tab, p, false, member.rect)
36+ p.resizeMu.Unlock()
34 return true37 return true
35 }38 }
36 39
@@ -636,6 +639,52 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
636 }639 }
637 }640 }
638 641
642+func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
643+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
644+ oldSize := domain.Size{Cols: 80, Rows: 24}
645+ newSize := domain.Size{Cols: 100, Rows: 40}
646+ previousGeometry := calculateContentFloatingGeometry(domain.Size{Cols: 60, Rows: 20}, cfg)
647+ oldGeometry := calculateContentFloatingGeometry(oldSize, cfg)
648+ newGeometry := calculateContentFloatingGeometry(newSize, cfg)
649+ pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
650+ p := newPane("floating", pty, rectSize(previousGeometry.Inner))
651+ p.rect = previousGeometry.Inner
652+ p.popupGeometry = previousGeometry
653+ tb := newTab(nil, oldSize)
654+ installTestFloating(tb, p, true)
655+ d := newTestDaemon(t, nil, stubClock{})
656+ d.ApplyConfig(domain.Config{Floating: cfg})
657+
658+ done := make(chan bool, 1)
659+ go func() {
660+ _, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
661+ done <- ok
662+ }()
663+ <-pty.entered
664+ tb.mu.Lock()
665+ tb.size = newSize
666+ tb.bumpLayoutGenerationLocked()
667+ tb.mu.Unlock()
668+ close(pty.release)
669+ require.True(t, <-done)
670+
671+ p.mu.Lock()
672+ require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle")
673+ require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry")
674+ require.Equal(t, previousGeometry.Inner.Width, p.screen.Frame.Width, "a stale floating apply must not resize the screen")
675+ require.Equal(t, previousGeometry.Inner.Height, p.screen.Frame.Height, "a stale floating apply must not resize the screen")
676+ require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate")
677+ p.mu.Unlock()
678+
679+ _, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
680+ require.True(t, ok)
681+ require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes())
682+ require.Equal(t, newGeometry.Inner, p.rect)
683+ require.Equal(t, newGeometry, p.popupGeometry)
684+ require.Equal(t, newGeometry.Inner.Width, p.screen.Frame.Width)
685+ require.Equal(t, newGeometry.Inner.Height, p.screen.Frame.Height)
686+}
687+
639 func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {688 func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
640 t.Run("failure preserves state", func(t *testing.T) {689 t.Run("failure preserves state", func(t *testing.T) {
641 pty := &resizePTY{err: errors.New("nope")}690 pty := &resizePTY{err: errors.New("nope")}
@@ -673,6 +722,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
673 require.Equal(t, second, p.popupGeometry)722 require.Equal(t, second, p.popupGeometry)
674 require.Equal(t, 1, pty.maxConcurrentCalls())723 require.Equal(t, 1, pty.maxConcurrentCalls())
675 require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())724 require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
725+ p.mu.Lock()
726+ applying, pending := p.resizeApplying, append([]byte(nil), p.resizePending...)
727+ p.mu.Unlock()
728+ require.False(t, applying, "successful helper apply must release its parser gate")
729+ require.Empty(t, pending, "successful helper apply must drain pending parser bytes")
676 })730 })
677 }731 }
678 732
M internal/usecase/daemon/input.go +37 -7
@@ -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()
@@ -89,9 +89,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
89 return89 return
90 }90 }
91 oldFocus := focusedID91 oldFocus := focusedID
92- focusPlacementLocked(tb, pl.ID)92+ layoutChanged := focusPlacementLocked(tb, pl.ID)
93- d.applyLayoutLocked(tb)
94 tb.mu.Unlock()93 tb.mu.Unlock()
94+ if layoutChanged {
95+ d.applyTabLayout(sess, tb)
96+ }
95 // A title bar never routes to terminal content. Clear any pre-existing97 // A title bar never routes to terminal content. Clear any pre-existing
96 // left-button candidate before handling the focus result, including when98 // left-button candidate before handling the focus result, including when
97 // this press leaves the same pane focused.99 // this press leaves the same pane focused.
@@ -108,13 +110,16 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
108 hoveredFocused := true110 hoveredFocused := true
109 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {111 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
110 oldFocus := focusedID112 oldFocus := focusedID
113+ layoutChanged := false
111 if isMouseFocusPress(ev) {114 if isMouseFocusPress(ev) {
112- focusPlacementLocked(tb, pl.ID)115+ layoutChanged = focusPlacementLocked(tb, pl.ID)
113- d.applyLayoutLocked(tb)
114 }116 }
115 p = tb.panes[pl.ID]117 p = tb.panes[pl.ID]
116 hoveredFocused = pl.ID == oldFocus118 hoveredFocused = pl.ID == oldFocus
117 tb.mu.Unlock()119 tb.mu.Unlock()
120+ if layoutChanged {
121+ d.applyTabLayout(sess, tb)
122+ }
118 if p == nil {123 if p == nil {
119 invalidateRejectedLeftPointer(rt, ev)124 invalidateRejectedLeftPointer(rt, ev)
120 return125 return
@@ -261,8 +266,9 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
261 }266 }
262 267
263 type daemonKeyHandler struct {268 type daemonKeyHandler struct {
264- d *Daemon269+ d *Daemon
265- ac *attachedClient270+ ac *attachedClient
271+ actions daemonActionRunner
266 }272 }
267 273
268 func (h daemonKeyHandler) Forward(data []byte) {274 func (h daemonKeyHandler) Forward(data []byte) {
@@ -285,6 +291,20 @@ func (h daemonKeyHandler) Action(action keys.Action) {
285 if sess == nil {291 if sess == nil {
286 return292 return
287 }293 }
294+ runResizeAction := func(request daemonActionRequest) {
295+ request.target = resolveDaemonActionTarget(sess)
296+ runner := h.actions
297+ if runner == nil {
298+ runner = daemonActions{d: h.d}
299+ }
300+ if err := runner.Run(request); err != nil {
301+ h.d.reportError(sess, resizeUserError(err))
302+ return
303+ }
304+ if h.actions == nil {
305+ finishDaemonActionForClient(h.d, request, h.ac, "input.go")
306+ }
307+ }
288 switch action {308 switch action {
289 case keys.ActionOpenPalette:309 case keys.ActionOpenPalette:
290 h.d.enterPalette(sess, h.ac)310 h.d.enterPalette(sess, h.ac)
@@ -313,6 +333,16 @@ func (h daemonKeyHandler) Action(action keys.Action) {
313 if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil {333 if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil {
314 h.d.reportError(sess, err)334 h.d.reportError(sess, err)
315 }335 }
336+ case keys.ActionGrowPaneWidth:
337+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
338+ case keys.ActionShrinkPaneWidth:
339+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
340+ case keys.ActionGrowPaneHeight:
341+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
342+ case keys.ActionShrinkPaneHeight:
343+ runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
344+ case keys.ActionEqualizePanes:
345+ runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes})
316 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,346 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
317 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,347 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
318 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:348 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +181 -0
@@ -26,6 +26,187 @@ import (
26 26
27 // --- test doubles -----------------------------------------------------------27 // --- test doubles -----------------------------------------------------------
28 28
29+func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ action keys.Action
33+ palette func(paletteExec) error
34+ control func(controlExec) error
35+ }{
36+ {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
37+ {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
38+ {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
39+ {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
40+ {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
41+ }
42+
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ d := newTestDaemon(t, nil, stubClock{})
46+ sess := addControlSession(d, "work", "t_work", "p_work")
47+ ac := &attachedClient{}
48+ ac.setSession(sess)
49+ target := resolveDaemonActionTarget(sess)
50+ keySpy := &actionRunnerSpy{}
51+ paletteSpy := &actionRunnerSpy{}
52+ controlSpy := &actionRunnerSpy{}
53+
54+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action)
55+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))
56+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))
57+
58+ require.Len(t, keySpy.requests, 1)
59+ require.Equal(t, keySpy.requests, paletteSpy.requests)
60+ require.Equal(t, keySpy.requests, controlSpy.requests)
61+ require.Same(t, sess, keySpy.requests[0].target.session)
62+ require.Same(t, target.tab, keySpy.requests[0].target.tab)
63+ require.Same(t, target.pane, keySpy.requests[0].target.pane)
64+ })
65+ }
66+}
67+
68+func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
69+ type adapterState struct {
70+ tree *layout.Tree
71+ placements []layout.Placement
72+ ptySizes map[layout.PaneID][]domain.Size
73+ generationDelta uint64
74+ snapshotDirtyDelta uint64
75+ }
76+ tests := []struct {
77+ name string
78+ action keys.Action
79+ palette func(paletteExec) error
80+ control func(controlExec) error
81+ }{
82+ {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }},
83+ {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }},
84+ {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }},
85+ {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }},
86+ {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }},
87+ }
88+
89+ for _, tt := range tests {
90+ t.Run(tt.name, func(t *testing.T) {
91+ run := func(t *testing.T, adapter string, attached bool) adapterState {
92+ t.Helper()
93+ ptys := map[layout.PaneID]*resizeActionPTY{
94+ "pane-1": {}, "pane-2": {}, "pane-3": {},
95+ }
96+ d, sess, ac, _ := newManualSessionWithPTYs(t, ptys["pane-1"])
97+ tb := sess.activeTab()
98+ p2 := newPane("pane-2", ptys["pane-2"], tb.size)
99+ p3 := newPane("pane-3", ptys["pane-3"], tb.size)
100+ tb.mu.Lock()
101+ tb.panes[p2.id] = p2
102+ tb.panes[p3.id] = p3
103+ tb.tree = &layout.Tree{
104+ Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
105+ layout.NewLeaf("pane-1"),
106+ {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{
107+ layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
108+ }},
109+ }},
110+ Focus: "pane-2",
111+ }
112+ tb.tree.Root.Children[0].Weight = 1
113+ tb.tree.Root.Children[1].Children[0].Weight = 1
114+ tb.tree.Root.Children[1].Children[1].Weight = 2
115+ generation := tb.layoutGeneration
116+ tb.mu.Unlock()
117+ sess.snapEligible.Store(true)
118+ sess.snapshotMu.Lock()
119+ dirty := sess.snapshotGeneration
120+ sess.snapshotMu.Unlock()
121+
122+ invalidations := make(chan renderInvalidation, 2)
123+ rc := newRenderCoordinator(renderCoordinatorOptions{
124+ wake: func(renderWake) {},
125+ onInvalidate: func(inv renderInvalidation) { invalidations <- inv },
126+ })
127+ sess.installRenderCoordinator(rc)
128+ if attached {
129+ rc.attach(ac)
130+ } else {
131+ sess.mu.Lock()
132+ sess.client = nil
133+ sess.mu.Unlock()
134+ }
135+
136+ switch adapter {
137+ case "key":
138+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
139+ case "palette":
140+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))
141+ case "control":
142+ target := resolveDaemonActionTarget(sess)
143+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target}))
144+ default:
145+ t.Fatalf("unknown adapter %q", adapter)
146+ }
147+
148+ tb.mu.Lock()
149+ tree := tb.tree.Clone()
150+ gotGeneration := tb.layoutGeneration
151+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
152+ tb.mu.Unlock()
153+ require.True(t, ok)
154+ sess.snapshotMu.Lock()
155+ gotDirty := sess.snapshotGeneration
156+ sess.snapshotMu.Unlock()
157+
158+ gotPTY := make(map[layout.PaneID][]domain.Size, len(ptys))
159+ for id, pty := range ptys {
160+ gotPTY[id] = pty.Sizes()
161+ }
162+ for _, placement := range placements {
163+ require.Equal(t, []domain.Size{{Cols: placement.Content.Width, Rows: placement.Content.Height}}, gotPTY[placement.ID], "canonical PTY resize for %s", placement.ID)
164+ }
165+ if attached {
166+ awaitInvalidation(t, invalidations)
167+ requireNoInvalidation(t, invalidations)
168+ } else {
169+ requireNoInvalidation(t, invalidations)
170+ }
171+ return adapterState{tree: tree, placements: placements, ptySizes: gotPTY, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty}
172+ }
173+
174+ keyState := run(t, "key", true)
175+ require.Equal(t, uint64(1), keyState.generationDelta)
176+ require.Equal(t, uint64(1), keyState.snapshotDirtyDelta)
177+ require.Equal(t, keyState, run(t, "palette", true))
178+ require.Equal(t, keyState, run(t, "control", true))
179+ headless := run(t, "control", false)
180+ require.Equal(t, uint64(1), headless.generationDelta)
181+ require.Equal(t, uint64(1), headless.snapshotDirtyDelta)
182+ require.Equal(t, keyState, headless)
183+ })
184+ }
185+}
186+
187+type resizeActionPTY struct {
188+ mu sync.Mutex
189+ sizes []domain.Size
190+}
191+
192+func (*resizeActionPTY) Read([]byte) (int, error) { return 0, nil }
193+func (*resizeActionPTY) Write(b []byte) (int, error) { return len(b), nil }
194+func (*resizeActionPTY) Close() error { return nil }
195+func (*resizeActionPTY) Pid() int { return 0 }
196+func (*resizeActionPTY) ForegroundPgid() (int, error) { return 0, nil }
197+func (p *resizeActionPTY) Resize(size domain.Size) error {
198+ p.mu.Lock()
199+ p.sizes = append(p.sizes, size)
200+ p.mu.Unlock()
201+ return nil
202+}
203+
204+func (p *resizeActionPTY) Sizes() []domain.Size {
205+ p.mu.Lock()
206+ defer p.mu.Unlock()
207+ return append([]domain.Size(nil), p.sizes...)
208+}
209+
29 // stubClock returns timers whose channel never fires, so a scheduler under it210 // stubClock returns timers whose channel never fires, so a scheduler under it
30 // blocks in its debounce loop until the session context is cancelled. Used by211 // blocks in its debounce loop until the session context is cancelled. Used by
31 212
M internal/usecase/daemon/mouse_panes.go +59 -2
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "math"
4 "strconv"5 "strconv"
5 "strings"6 "strings"
6 7
@@ -66,10 +67,41 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
66 67
67 func layoutFingerprint(root *layout.Node) string {68 func layoutFingerprint(root *layout.Node) string {
68 var b strings.Builder69 var b strings.Builder
70+ b.Grow(layoutFingerprintLength(root))
69 writeLayoutFingerprint(&b, root)71 writeLayoutFingerprint(&b, root)
70 return b.String()72 return b.String()
71 }73 }
72 74
75+const weightFingerprintMaxLength = 16
76+
77+func layoutFingerprintLength(n *layout.Node) int {
78+ if n == nil {
79+ return 1
80+ }
81+ length := 2 + weightFingerprintMaxLength + 1 + paneIDFingerprintLength(n.Leaf) + 1 + paneIDFingerprintLength(n.Expanded) + 2
82+ for _, child := range n.Children {
83+ length += layoutFingerprintLength(child) + 1
84+ }
85+ return length
86+}
87+
88+func paneIDFingerprintLength(id layout.PaneID) int {
89+ length := len(id)
90+ return decimalDigits(length) + 1 + length
91+}
92+
93+func decimalDigits(value int) int {
94+ if value == 0 {
95+ return 1
96+ }
97+ digits := 0
98+ for value > 0 {
99+ value /= 10
100+ digits++
101+ }
102+ return digits
103+}
104+
73 func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {105 func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
74 if n == nil {106 if n == nil {
75 b.WriteByte('0')107 b.WriteByte('0')
@@ -77,6 +109,8 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
77 }109 }
78 b.WriteByte(byte('0' + n.Kind))110 b.WriteByte(byte('0' + n.Kind))
79 b.WriteByte(byte('0' + n.Dir))111 b.WriteByte(byte('0' + n.Dir))
112+ writeWeightFingerprint(b, n.Weight)
113+ b.WriteByte('|')
80 writePaneIDFingerprint(b, n.Leaf)114 writePaneIDFingerprint(b, n.Leaf)
81 b.WriteByte('|')115 b.WriteByte('|')
82 writePaneIDFingerprint(b, n.Expanded)116 writePaneIDFingerprint(b, n.Expanded)
@@ -88,6 +122,22 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
88 b.WriteByte(']')122 b.WriteByte(']')
89 }123 }
90 124
125+func writeWeightFingerprint(b *strings.Builder, weight float64) {
126+ const hex = "0123456789abcdef"
127+ bits := math.Float64bits(weight)
128+ started := false
129+ for shift := uint(60); ; shift -= 4 {
130+ digit := byte(bits >> shift & 0xf)
131+ if digit != 0 || started || shift == 0 {
132+ b.WriteByte(hex[digit])
133+ started = true
134+ }
135+ if shift == 0 {
136+ return
137+ }
138+ }
139+}
140+
91 func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) {141 func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) {
92 s := string(id)142 s := string(id)
93 b.WriteString(strconv.Itoa(len(s)))143 b.WriteString(strconv.Itoa(len(s)))
@@ -99,12 +149,19 @@ func pointInRect(col, row int, r domain.Rect) bool {
99 return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height149 return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height
100 }150 }
101 151
102-func focusPlacementLocked(tb *tab, id layout.PaneID) {152+func focusPlacementLocked(tb *tab, id layout.PaneID) bool {
103 if tb == nil || tb.tree == nil {153 if tb == nil || tb.tree == nil {
104- return154+ return false
105 }155 }
156+ oldFocus := tb.tree.Focus
157+ oldLayout := layoutFingerprint(tb.tree.Root)
106 tb.tree.Focus = id158 tb.tree.Focus = id
107 setExpandedLocked(tb.tree.Root, id)159 setExpandedLocked(tb.tree.Root, id)
160+ changed := oldFocus != tb.tree.Focus || oldLayout != layoutFingerprint(tb.tree.Root)
161+ if changed {
162+ tb.bumpLayoutGenerationLocked()
163+ }
164+ return changed
108 }165 }
109 166
110 func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {167 func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
M internal/usecase/daemon/navigation_overflow.go +5 -3
@@ -228,17 +228,19 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate
228 sess.mu.Unlock()228 sess.mu.Unlock()
229 return false229 return false
230 }230 }
231- if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil {231+ targetTree := candidate.target.tree.Clone()
232+ if err := targetTree.FocusEnter(candidate.direction, candidate.span, area); err != nil {
232 candidate.target.mu.Unlock()233 candidate.target.mu.Unlock()
233 candidate.source.mu.Unlock()234 candidate.source.mu.Unlock()
234 sess.mu.Unlock()235 sess.mu.Unlock()
235 return false236 return false
236 }237 }
237- d.applyLayoutLocked(candidate.target)238+ candidate.target.tree = targetTree
239+ candidate.target.bumpLayoutGenerationLocked()
238 sess.active = targetIndex240 sess.active = targetIndex
239 candidate.target.mu.Unlock()241 candidate.target.mu.Unlock()
240 candidate.source.mu.Unlock()242 candidate.source.mu.Unlock()
241 sess.mu.Unlock()243 sess.mu.Unlock()
242- markSnapshotDirty(sess)244+ d.applyTabLayout(sess, candidate.target)
243 return true245 return true
244 }246 }
M internal/usecase/daemon/nil_pty_test.go +5 -1
@@ -23,8 +23,12 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
23 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})23 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
24 sess.tabs = []*tab{first, second}24 sess.tabs = []*tab{first, second}
25 25
26+ first.mu.Lock()
27+ first.size = domain.Size{Cols: 20, Rows: 5}
28+ first.bumpLayoutGenerationLocked()
29+ first.mu.Unlock()
26 require.NotPanics(t, func() {30 require.NotPanics(t, func() {
27- d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5})31+ d.applyTabLayout(sess, first)
28 })32 })
29 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)33 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
30 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)34 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
M internal/usecase/daemon/overlay.go +10 -0
@@ -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 {
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) }
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) {
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/pane.go +2 -1
@@ -18,7 +18,7 @@ type pane struct {
18 id layout.PaneID18 id layout.PaneID
19 stableID string19 stableID string
20 pty ports.PTY20 pty ports.PTY
21- mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, and title
22 resizeMu sync.Mutex // serializes PTY resizes without holding mu22 resizeMu sync.Mutex // serializes PTY resizes without holding mu
23 screen *vt.Screen23 screen *vt.Screen
24 history *vt.History24 history *vt.History
@@ -28,6 +28,7 @@ type pane struct {
28 // draining into resizePending so output is replayed against the target (or28 // draining into resizePending so output is replayed against the target (or
29 // retained old) screen only after apply resolves.29 // retained old) screen only after apply resolves.
30 resizeApplying bool30 resizeApplying bool
31+ resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan.
31 resizePending []byte32 resizePending []byte
32 ptyResponses []byte33 ptyResponses []byte
33 ptyClipboards []string34 ptyClipboards []string
M internal/usecase/daemon/pane_actions.go +25 -73
@@ -56,7 +56,8 @@ func (d *Daemon) spawnPaneOpAt(
56 }56 }
57 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))57 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
58 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}58 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
59- if err := mutate(tb.tree, oldFocus, newID, area); err != nil {59+ candidate := tb.tree.Clone()
60+ if err := mutate(candidate, oldFocus, newID, area); err != nil {
60 tb.mu.Unlock()61 tb.mu.Unlock()
61 if errors.Is(err, layout.ErrTooSmall) {62 if errors.Is(err, layout.ErrTooSmall) {
62 return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err)63 return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err)
@@ -64,33 +65,24 @@ func (d *Daemon) spawnPaneOpAt(
64 return err65 return err
65 }66 }
66 tb.nextPaneID++67 tb.nextPaneID++
67- placements, ok := layout.Solve(tb.tree.Root, area)68+ placements, ok := layout.Solve(candidate.Root, area)
68 if !ok {69 if !ok {
69- _ = tb.tree.Close(newID)
70- tb.tree.Focus = oldFocus
71 tb.mu.Unlock()70 tb.mu.Unlock()
72 return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall)71 return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall)
73 }72 }
74 newRect := placementContent(placements, newID)73 newRect := placementContent(placements, newID)
75 tabStableID := tb.stableID74 tabStableID := tb.stableID
75+ generation := tb.layoutGeneration
76 tb.mu.Unlock()76 tb.mu.Unlock()
77 77
78 paneStableID, err := newStableID("p")78 paneStableID, err := newStableID("p")
79 if err != nil {79 if err != nil {
80- tb.mu.Lock()
81- _ = tb.tree.Close(newID)
82- tb.tree.Focus = oldFocus
83- tb.mu.Unlock()
84 return fmt.Errorf("daemon: generating pane identity: %w", err)80 return fmt.Errorf("daemon: generating pane identity: %w", err)
85 }81 }
86 command, args := d.ptyCommand(env)82 command, args := d.ptyCommand(env)
87 pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))83 pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
88 if err != nil {84 if err != nil {
89 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")85 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
90- tb.mu.Lock()
91- _ = tb.tree.Close(newID)
92- tb.tree.Focus = oldFocus
93- tb.mu.Unlock()
94 return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)86 return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
95 }87 }
96 88
@@ -100,65 +92,22 @@ func (d *Daemon) spawnPaneOpAt(
100 p.rect = newRect92 p.rect = newRect
101 93
102 tb.mu.Lock()94 tb.mu.Lock()
103- if _, ok := tb.panes[newID]; ok || !layout.ContainsLeaf(tb.tree.Root, newID) {95+ if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
104 tb.mu.Unlock()96 tb.mu.Unlock()
105 cancel()97 cancel()
106 _ = pty.Close()98 _ = pty.Close()
107 return layout.ErrNotFound99 return layout.ErrNotFound
108 }100 }
101+ tb.tree = candidate
109 tb.panes[newID] = p102 tb.panes[newID] = p
110- tb.tree.Focus = newID103+ tb.bumpLayoutGenerationLocked()
111- d.applyLayoutLocked(tb)
112 tb.mu.Unlock()104 tb.mu.Unlock()
113 105
106+ d.applyTabLayout(sess, tb)
114 d.startPaneGoroutines(sess, tb, p)107 d.startPaneGoroutines(sess, tb, p)
115- markSnapshotDirty(sess)
116 return nil108 return nil
117 }109 }
118 110
119-func (d *Daemon) applyLayoutLocked(tb *tab) {
120- if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
121- return
122- }
123- placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
124- if !ok {
125- for _, p := range tb.panes {
126- d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
127- }
128- return
129- }
130- for _, pl := range placements {
131- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
132- continue
133- }
134- if p := tb.panes[pl.ID]; p != nil {
135- d.applyPaneResize(p, pl.Content)
136- }
137- }
138-}
139-
140-func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) {
141- if p == nil {
142- return
143- }
144- p.mu.Lock()
145- old := p.rect
146- p.mu.Unlock()
147- if old == r {
148- return
149- }
150- sz := rectSize(r)
151- if p.pty != nil {
152- if err := p.pty.Resize(sz); err != nil {
153- d.log.Warn("pty resize failed", "err", err)
154- }
155- }
156- p.mu.Lock()
157- p.screen.Resize(sz.Cols, sz.Rows)
158- p.rect = r
159- p.mu.Unlock()
160-}
161-
162 func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect {111 func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect {
163 for _, pl := range placements {112 for _, pl := range placements {
164 if pl.ID == id {113 if pl.ID == id {
@@ -210,13 +159,15 @@ func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
210 tb.mu.Unlock()159 tb.mu.Unlock()
211 return layout.ErrNotFound160 return layout.ErrNotFound
212 }161 }
213- err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})162+ candidate := tb.tree.Clone()
163+ err := candidate.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
214 if err == nil {164 if err == nil {
215- d.applyLayoutLocked(tb)165+ tb.tree = candidate
166+ tb.bumpLayoutGenerationLocked()
216 }167 }
217 tb.mu.Unlock()168 tb.mu.Unlock()
218 if err == nil {169 if err == nil {
219- markSnapshotDirty(sess)170+ d.applyTabLayout(sess, tb)
220 }171 }
221 return err172 return err
222 }173 }
@@ -276,8 +227,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
276 return err227 return err
277 }228 }
278 delete(tb.panes, id)229 delete(tb.panes, id)
279- d.applyLayoutLocked(tb)230+ tb.bumpLayoutGenerationLocked()
280 tb.mu.Unlock()231 tb.mu.Unlock()
232+ d.applyTabLayout(sess, tb)
281 233
282 if ac != nil {234 if ac != nil {
283 ac.overlays.clearCopyModeForPane(p)235 ac.overlays.clearCopyModeForPane(p)
@@ -294,7 +246,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
294 _ = p.pty.Close()246 _ = p.pty.Close()
295 }247 }
296 d.log.Info("pane closed", "session", sess.name, "pane", id)248 d.log.Info("pane closed", "session", sess.name, "pane", id)
297- markSnapshotDirty(sess)
298 if repaint {249 if repaint {
299 if ac != nil {250 if ac != nil {
300 d.invalidateRender(sess, ac, true, "pane_actions.go")251 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -375,18 +326,19 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
375 tb.mu.Unlock()326 tb.mu.Unlock()
376 return domain.Rect{}, layout.ErrNotFound327 return domain.Rect{}, layout.ErrNotFound
377 }328 }
378- oldFocus := target.id329+ candidate := tb.tree.Clone()
379- tb.tree.Focus = oldFocus330+ candidate.Focus = target.id
380 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}331 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
381- span, _ := tb.tree.FocusSpan(area)332+ span, _ := candidate.FocusSpan(area)
382- err := tb.tree.FocusDir(dir, area)333+ err := candidate.FocusDir(dir, area)
383- newFocus := tb.tree.Focus334+ committed := err == nil && candidate.Focus != tb.tree.Focus
384- if err == nil {335+ if committed {
385- d.applyLayoutLocked(tb)336+ tb.tree = candidate
337+ tb.bumpLayoutGenerationLocked()
386 }338 }
387 tb.mu.Unlock()339 tb.mu.Unlock()
388- if err == nil && newFocus != oldFocus {340+ if committed {
389- markSnapshotDirty(sess)341+ d.applyTabLayout(sess, tb)
390 }342 }
391 if errors.Is(err, layout.ErrNoPane) {343 if errors.Is(err, layout.ErrNoPane) {
392 return span, errNoNeighbor344 return span, errNoNeighbor
M internal/usecase/daemon/pane_actions_test.go +433 -3
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "io"6 "io"
7 "log/slog"7 "log/slog"
8+ "sync"
8 "testing"9 "testing"
9 "time"10 "time"
10 11
@@ -19,6 +20,437 @@ import (
19 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
20 )21 )
21 22
23+func TestLayoutGenerationMutationBookkeeping(t *testing.T) {
24+ t.Run("focus and expanded member share one logical bump", func(t *testing.T) {
25+ tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
26+ second := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 10})
27+ tb.mu.Lock()
28+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}, Focus: "pane-1"}
29+ tb.panes[second.id] = second
30+ require.Zero(t, tb.layoutGeneration)
31+ require.True(t, focusPlacementLocked(tb, second.id))
32+ require.Equal(t, uint64(1), tb.layoutGeneration)
33+ require.False(t, focusPlacementLocked(tb, second.id))
34+ require.Equal(t, uint64(1), tb.layoutGeneration)
35+ tb.mu.Unlock()
36+ })
37+
38+ t.Run("size changes bump only when the value changes", func(t *testing.T) {
39+ tb := newTab(nil, domain.Size{Cols: 41, Rows: 10})
40+ tb.mu.Lock()
41+ if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
42+ tb.size = domain.Size{Cols: 80, Rows: 23}
43+ tb.bumpLayoutGenerationLocked()
44+ }
45+ require.Equal(t, uint64(1), tb.layoutGeneration)
46+ if tb.size != (domain.Size{Cols: 80, Rows: 23}) {
47+ tb.size = domain.Size{Cols: 80, Rows: 23}
48+ tb.bumpLayoutGenerationLocked()
49+ }
50+ require.Equal(t, uint64(1), tb.layoutGeneration)
51+ tb.mu.Unlock()
52+ })
53+}
54+
55+type layoutRetryClock struct{ timers chan *layoutRetryTimer }
56+
57+type layoutRetryTimer struct {
58+ ch chan time.Time
59+ delay time.Duration
60+}
61+
62+func (c *layoutRetryClock) Now() time.Time { return time.Unix(0, 0) }
63+
64+func (c *layoutRetryClock) NewTimer(delay time.Duration) ports.Timer {
65+ timer := &layoutRetryTimer{ch: make(chan time.Time, 1), delay: delay}
66+ c.timers <- timer
67+ return timer
68+}
69+
70+func (t *layoutRetryTimer) C() <-chan time.Time { return t.ch }
71+func (*layoutRetryTimer) Reset(time.Duration) bool { return false }
72+func (*layoutRetryTimer) Stop() bool { return true }
73+func (t *layoutRetryTimer) fire() { t.ch <- time.Unix(0, 0) }
74+
75+func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
76+ t.Run("persistent failure is bounded and reports once", func(t *testing.T) {
77+ pty := &transactionalResizePTY{errs: []error{errors.New("initial"), errors.New("retry 1"), errors.New("retry 2"), errors.New("retry 3")}}
78+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
79+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
80+ d.clock = clock
81+ tb := sess.activeTab()
82+ tb.mu.Lock()
83+ tb.size = domain.Size{Cols: 100, Rows: 20}
84+ tb.bumpLayoutGenerationLocked()
85+ tb.mu.Unlock()
86+
87+ d.applyTabLayout(sess, tb)
88+ // A second accepted failure joins the existing worker rather than adding
89+ // another timer/goroutine.
90+ d.scheduleAcceptedTabLayoutRetry(sess, tb)
91+ for range maxAcceptedTabLayoutRetries {
92+ var timer *layoutRetryTimer
93+ for timer == nil {
94+ candidate := <-clock.timers
95+ if candidate.delay == minOutputRenderDeadline {
96+ timer = candidate
97+ }
98+ }
99+ timer.fire()
100+ }
101+ require.Eventually(t, func() bool {
102+ tb.layoutRetryMu.Lock()
103+ defer tb.layoutRetryMu.Unlock()
104+ return !tb.layoutRetryRunning
105+ }, time.Second, time.Millisecond)
106+ require.Len(t, pty.requested(), 1+maxAcceptedTabLayoutRetries)
107+ count := 0
108+ for _, notice := range d.notices.history() {
109+ if notice.Code == domain.NoticeResizeFailed {
110+ count++
111+ }
112+ }
113+ require.Equal(t, 1, count, "background retries must not create a notification storm")
114+ })
115+
116+ t.Run("tab cancellation stops a waiting retry", func(t *testing.T) {
117+ pty := &transactionalResizePTY{errs: []error{errors.New("initial")}}
118+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
119+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
120+ d.clock = clock
121+ tb := sess.activeTab()
122+ tb.mu.Lock()
123+ tb.size = domain.Size{Cols: 100, Rows: 20}
124+ tb.bumpLayoutGenerationLocked()
125+ tb.mu.Unlock()
126+ d.applyTabLayout(sess, tb)
127+ var timer *layoutRetryTimer
128+ for timer == nil {
129+ candidate := <-clock.timers
130+ if candidate.delay == minOutputRenderDeadline {
131+ timer = candidate
132+ }
133+ }
134+ tb.cancel()
135+ timer.fire()
136+ require.Eventually(t, func() bool {
137+ tb.layoutRetryMu.Lock()
138+ defer tb.layoutRetryMu.Unlock()
139+ return !tb.layoutRetryRunning
140+ }, time.Second, time.Millisecond)
141+ require.Len(t, pty.requested(), 1)
142+ })
143+}
144+
145+func TestFocusDirAtDoesNotApplyUncommittedCandidate(t *testing.T) {
146+ pty := &transactionalResizePTY{}
147+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
148+ tb := sess.activeTab()
149+ second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
150+ tb.mu.Lock()
151+ tb.panes[second.id] = second
152+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
153+ tb.panes["pane-1"].resizeRetry = true
154+ target := tb.panes["pane-1"]
155+ tb.mu.Unlock()
156+
157+ _, err := d.focusDirAt(sess, tb, target, layout.Right)
158+ require.NoError(t, err)
159+ require.Empty(t, pty.requested(), "an equality-mismatched candidate was not committed and must not apply")
160+}
161+
162+func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
163+ pty := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
164+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
165+ clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)}
166+ d.clock = clock
167+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
168+ tb.mu.Lock()
169+ tb.size = domain.Size{Cols: 100, Rows: 20}
170+ tb.bumpLayoutGenerationLocked()
171+ tb.mu.Unlock()
172+
173+ secondResize := make(chan struct{})
174+ var resized sync.Once
175+ pty.onResize = func() {
176+ if len(pty.requested()) == 2 {
177+ resized.Do(func() { close(secondResize) })
178+ }
179+ }
180+ d.applyTabLayout(sess, tb)
181+
182+ var timer *layoutRetryTimer
183+ for timer == nil {
184+ candidate := <-clock.timers
185+ if candidate.delay == minOutputRenderDeadline {
186+ timer = candidate
187+ }
188+ }
189+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested())
190+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize")
191+ timer.fire()
192+ awaitSignal(t, secondResize, "accepted failure was not retried")
193+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested())
194+ require.Eventually(t, func() bool {
195+ p.mu.Lock()
196+ defer p.mu.Unlock()
197+ return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20
198+ }, time.Second, time.Millisecond, "successful retry did not publish its VT size")
199+}
200+
201+func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
202+ clock := newCoordinatorMockClock(t, 8)
203+ // The failed apply also posts its preserved degradation warning; its toast
204+ // lifetime is unrelated to the coordinator deadlines driven below.
205+ clock.clock.EXPECT().NewTimer(6 * time.Second).Return(stubTimer{}).Once()
206+ failed := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}}
207+ d, sess, ac, _ := newManualSessionWithPTYs(t, failed)
208+ d.clock = clock.clock
209+ tb := sess.activeTab()
210+ first := tb.focusedPane()
211+ second := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 23})
212+ tb.mu.Lock()
213+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(first.id), layout.NewLeaf(second.id)}}, Focus: first.id}
214+ tb.panes[second.id] = second
215+ tb.bumpLayoutGenerationLocked()
216+ tb.mu.Unlock()
217+
218+ // A client resize commits the new layout but leaves the failed PTY on the
219+ // coordinator retry lane. Drive both deadlines explicitly so this ordering
220+ // cannot depend on wall-clock scheduling.
221+ d.resize(sess, ac, domain.Size{Cols: 80, Rows: 24})
222+ rc := sess.renderCoordinator()
223+ resizeTimer := awaitCoordinatorScheduledTimer(t, clock)
224+ resizeDone := captureResizeCallbackDone(t, rc)
225+ resizeTimer.ch <- time.Time{}
226+ awaitTestCompletion(t, resizeDone, "failed client resize did not complete")
227+ var retryTimer *coordinatorMockTimer
228+ for retryTimer == nil {
229+ candidate := awaitCoordinatorScheduledTimer(t, clock)
230+ if candidate.duration == minOutputRenderDeadline {
231+ retryTimer = candidate
232+ }
233+ }
234+
235+ // A later pane-resize changes the solved rectangle and repairs the failed
236+ // PTY before the delayed callback fires. Retrying its old captured 40-column
237+ // rectangle would regress the PTY, VT, and published pane geometry.
238+ require.NoError(t, d.resizePane(daemonActionTarget{session: sess, tab: tb, pane: first}, layout.Width, resizeStepCols))
239+ tb.mu.Lock()
240+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
241+ currentFirst := placementContent(placements, first.id)
242+ currentSecond := placementContent(placements, second.id)
243+ tb.mu.Unlock()
244+ require.True(t, ok)
245+ require.Equal(t, currentFirst, first.rect)
246+ require.Equal(t, currentSecond, second.rect)
247+ require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height})
248+ require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested())
249+
250+ rc.mu.Lock()
251+ retryDone := rc.retryLane.token.done
252+ rc.mu.Unlock()
253+ retryTimer.ch <- time.Time{}
254+ awaitTestCompletion(t, retryDone, "delayed resize retry did not complete")
255+
256+ require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry")
257+ require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle")
258+ require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height}, "delayed retry must not publish a stale VT size")
259+}
260+
261+func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
262+ d, sess, pty, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
263+ tb := sess.activeTab()
264+ tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}
265+ entered := make(chan struct{})
266+ release := make(chan struct{})
267+ pty.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) {
268+ close(entered)
269+ <-release
270+ }).Return(nil).Once()
271+
272+ done := make(chan struct{})
273+ go func() {
274+ d.applyTabLayout(sess, tb)
275+ close(done)
276+ }()
277+ awaitSignal(t, entered, "PTY resize did not start")
278+
279+ locked := make(chan struct{})
280+ go func() {
281+ tb.mu.Lock()
282+ _ = tb.layoutGeneration
283+ tb.mu.Unlock()
284+ close(locked)
285+ }()
286+ awaitSignal(t, locked, "tab lock remained held across PTY resize")
287+ close(release)
288+ awaitSignal(t, done, "layout application did not finish")
289+}
290+
291+func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
292+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
293+ tb := sess.activeTab()
294+ oldPane := tb.focusedPane()
295+ oldPane.rect = domain.Rect{Width: 20, Height: 10}
296+ oldPane.screen.Resize(20, 10)
297+ entered := make(chan struct{})
298+ release := make(chan struct{})
299+ oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) {
300+ close(entered)
301+ <-release
302+ }).Return(nil).Once()
303+
304+ done := make(chan struct{})
305+ go func() {
306+ d.applyTabLayout(sess, tb)
307+ close(done)
308+ }()
309+ awaitSignal(t, entered, "stale PTY resize did not start")
310+
311+ newPTY := portsmocks.NewMockPTY(t)
312+ replacement := newPane("pane-1", newPTY, domain.Size{Cols: 20, Rows: 10})
313+ newPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
314+ tb.mu.Lock()
315+ tb.panes["pane-1"] = replacement
316+ tb.bumpLayoutGenerationLocked()
317+ tb.mu.Unlock()
318+ close(release)
319+ awaitSignal(t, done, "stale layout did not retry")
320+
321+ require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published")
322+ require.Equal(t, 20, oldPane.screen.Frame.Width, "stale pane screen was published")
323+ require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect)
324+ require.Equal(t, 41, replacement.screen.Frame.Width)
325+}
326+
327+func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
328+ newBlockedPTY := func() (*transactionalResizePTY, <-chan struct{}, chan<- struct{}) {
329+ entered := make(chan struct{})
330+ release := make(chan struct{})
331+ var once sync.Once
332+ return &transactionalResizePTY{onResize: func() {
333+ once.Do(func() {
334+ close(entered)
335+ <-release
336+ })
337+ }}, entered, release
338+ }
339+
340+ t.Run("close removes a stale plan member", func(t *testing.T) {
341+ first, entered, release := newBlockedPTY()
342+ second := &transactionalResizePTY{}
343+ d, sess, _, _ := newManualSessionWithPTYs(t, first)
344+ tb := sess.activeTab()
345+ p1 := tb.focusedPane()
346+ p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
347+ tb.mu.Lock()
348+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}}, Focus: p1.id}
349+ tb.panes[p2.id] = p2
350+ tb.bumpLayoutGenerationLocked()
351+ tb.mu.Unlock()
352+
353+ done := make(chan struct{})
354+ go func() { d.applyTabLayout(sess, tb); close(done) }()
355+ awaitSignal(t, entered, "old layout apply did not block")
356+ closed := make(chan error, 1)
357+ go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }()
358+ require.Eventually(t, func() bool {
359+ tb.mu.Lock()
360+ defer tb.mu.Unlock()
361+ return tb.panes[p2.id] == nil && tb.layoutGeneration > 1
362+ }, time.Second, time.Millisecond, "close must mutate before its serialized apply waits")
363+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "stale plan published before release")
364+ close(release)
365+ awaitSignal(t, done, "stale layout apply did not finish")
366+ require.NoError(t, <-closed)
367+
368+ tb.mu.Lock()
369+ _, exists := tb.panes[p2.id]
370+ tb.mu.Unlock()
371+ require.False(t, exists)
372+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication")
373+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Frame.Width, Rows: p2.screen.Frame.Height}, "removed pane received a stale screen publication")
374+ p2.mu.Lock()
375+ require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")
376+ p2.mu.Unlock()
377+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect)
378+ require.Equal(t, []domain.Size{{Cols: 40, Rows: 23}, {Cols: 80, Rows: 23}}, first.requested(), "the rejected plan must be followed by a fresh surviving-pane apply")
379+ require.Equal(t, []domain.Size{{Cols: 39, Rows: 23}}, second.requested(), "the removed pane may receive an obsolete external resize but must never be published")
380+ })
381+
382+ t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) {
383+ first, entered, release := newBlockedPTY()
384+ second := &transactionalResizePTY{}
385+ d, sess, _, _ := newManualSessionWithPTYs(t, first)
386+ tb := sess.activeTab()
387+ p1 := tb.focusedPane()
388+ p2 := newPane("pane-2", second, domain.Size{Cols: 80, Rows: 23})
389+ tb.mu.Lock()
390+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p1.id}, Focus: p1.id}
391+ tb.panes[p2.id] = p2
392+ tb.bumpLayoutGenerationLocked()
393+ tb.mu.Unlock()
394+
395+ done := make(chan struct{})
396+ go func() { d.applyTabLayout(sess, tb); close(done) }()
397+ awaitSignal(t, entered, "old stack apply did not block")
398+ tb.mu.Lock()
399+ require.True(t, focusPlacementLocked(tb, p2.id))
400+ tb.mu.Unlock()
401+ close(release)
402+ awaitSignal(t, done, "stale stack apply did not retry")
403+
404+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect, "collapsed pane received stale geometry")
405+ p1.mu.Lock()
406+ require.False(t, p1.resizeApplying, "collapsed pane gate was not cancelled")
407+ p1.mu.Unlock()
408+ tb.mu.Lock()
409+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
410+ tb.mu.Unlock()
411+ require.True(t, ok)
412+ require.Equal(t, placementContent(placements, p2.id), p2.rect)
413+ require.Equal(t, []domain.Size{{Cols: 80, Rows: 22}}, first.requested(), "the old expanded pane is applied only by the rejected attempt")
414+ require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested())
415+ })
416+
417+ t.Run("client size rejects and retries a blocked plan", func(t *testing.T) {
418+ pty, entered, release := newBlockedPTY()
419+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
420+ tb := sess.activeTab()
421+ p := tb.focusedPane()
422+ p.rect = domain.Rect{Width: 20, Height: 10}
423+
424+ oldDone := make(chan struct{})
425+ go func() { d.applyTabLayout(sess, tb); close(oldDone) }()
426+ awaitSignal(t, entered, "old size apply did not block")
427+ resizeDone := make(chan bool, 1)
428+ go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }()
429+ // Session resize now stages the target privately until every external
430+ // apply validates and the transaction is admitted, so neither the size
431+ // nor pane geometry is visible while the old PTY call is blocked.
432+ tb.mu.Lock()
433+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
434+ tb.mu.Unlock()
435+ require.Equal(t, domain.Rect{Width: 20, Height: 10}, p.rect, "stale size published before release")
436+ close(release)
437+ awaitSignal(t, oldDone, "old size apply did not finish")
438+ require.True(t, <-resizeDone)
439+ require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect)
440+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
441+ require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested())
442+ })
443+}
444+
445+func awaitSignal(t *testing.T, ch <-chan struct{}, message string) {
446+ t.Helper()
447+ select {
448+ case <-ch:
449+ case <-time.After(2 * time.Second):
450+ t.Fatal(message)
451+ }
452+}
453+
22 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {454 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
23 tests := []struct {455 tests := []struct {
24 name string456 name string
@@ -123,9 +555,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
123 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()555 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
124 newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()556 newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
125 557
126- tb.mu.Lock()558+ d.applyTabLayout(sess, tb)
127- d.applyLayoutLocked(tb)
128- tb.mu.Unlock()
129 559
130 require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width)560 require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width)
131 require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width)561 require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width)
M internal/usecase/daemon/performance_benchmark_test.go +18 -5
@@ -1110,7 +1110,6 @@ func (f *performanceFixture) close() error {
1110 func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {1110 func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {
1111 f.t.Helper()1111 f.t.Helper()
1112 tb.mu.Lock()1112 tb.mu.Lock()
1113- defer tb.mu.Unlock()
1114 tb.size = size1113 tb.size = size
1115 left := tb.focusedPane()1114 left := tb.focusedPane()
1116 for paneIndex := 1; paneIndex < paneCount; paneIndex++ {1115 for paneIndex := 1; paneIndex < paneCount; paneIndex++ {
@@ -1121,8 +1120,13 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
1121 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})1120 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
1122 require.True(f.t, ok, "performance fixture layout must be solvable")1121 require.True(f.t, ok, "performance fixture layout must be solvable")
1123 require.Len(f.t, placements, paneCount)1122 require.Len(f.t, placements, paneCount)
1124- f.d.applyLayoutLocked(tb)1123+ tb.bumpLayoutGenerationLocked()
1125- for _, p := range tb.panes {1124+ tb.mu.Unlock()
1125+ _, _ = f.d.applyTabLayoutTransaction(f.sess, tb)
1126+ tb.mu.Lock()
1127+ panes := tb.panesSnapshot()
1128+ tb.mu.Unlock()
1129+ for _, p := range panes {
1126 p.mu.Lock()1130 p.mu.Lock()
1127 width := p.screen.Frame.Width1131 width := p.screen.Frame.Width
1128 for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ {1132 for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ {
@@ -1247,9 +1251,18 @@ func (f *performanceFixture) resizeTo(size domain.Size) {
1247 1251
1248 func (f *performanceFixture) retryLatest() {1252 func (f *performanceFixture) retryLatest() {
1249 epoch := f.sess.renderCoordinator().resizeSnapshot().committed1253 epoch := f.sess.renderCoordinator().resizeSnapshot().committed
1250- plan := f.d.prepareResize(f.sess, f.ac.size)1254+ f.sess.mu.Lock()
1255+ tabs := append([]*tab(nil), f.sess.tabs...)
1256+ f.sess.mu.Unlock()
1257+ members := make([]resizeMember, 0)
1258+ for _, tb := range tabs {
1259+ tb.mu.Lock()
1260+ plan := prepareTabLayoutForSizeLocked(f.sess, tb, tabSize(f.ac.size))
1261+ tb.mu.Unlock()
1262+ members = append(members, plan.members...)
1263+ }
1251 failuresBefore, callsBefore := f.pty.metrics()1264 failuresBefore, callsBefore := f.pty.metrics()
1252- f.d.retryResizeMembers(f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, plan.members)1265+ f.d.retryResizeMembers(f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, members)
1253 failuresAfter, callsAfter := f.pty.metrics()1266 failuresAfter, callsAfter := f.pty.metrics()
1254 f.ptyFailures += failuresAfter - failuresBefore1267 f.ptyFailures += failuresAfter - failuresBefore
1255 if callsAfter > callsBefore {1268 if callsAfter > callsBefore {
M internal/usecase/daemon/render.go +6 -2
@@ -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 {
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
M internal/usecase/daemon/render_pipeline_cache_test.go +39 -0
@@ -125,6 +125,45 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes
125 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)125 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
126 }126 }
127 127
128+func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) {
129+ root := &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
130+ {Kind: layout.Leaf, Leaf: "left", Weight: 1},
131+ {Kind: layout.Leaf, Leaf: "right", Weight: 1},
132+ }}
133+ stateFor := func(reset bool, damage []renderer.Damage) capturedRenderState {
134+ placements, ok := layout.Solve(root, domain.Rect{Width: 61, Height: 5})
135+ require.True(t, ok)
136+ panes := make([]capturedPaneRenderState, 0, len(placements))
137+ for _, placement := range placements {
138+ frame := renderer.NewFrame(placement.Content.Width, placement.Content.Height)
139+ for y := 0; y < frame.Height; y++ {
140+ for x := 0; x < frame.Width; x++ {
141+ frame.Set(x, y, renderer.Cell{Rune: rune(placement.ID[0]), Style: renderer.DefaultStyle()})
142+ }
143+ }
144+ panes = append(panes, capturedPaneRenderState{
145+ id: placement.ID, frame: frame, placement: placement, focused: placement.ID == "left", damage: damage,
146+ })
147+ }
148+ return capturedRenderState{
149+ reset: reset,
150+ layout: capturedTabLayout{root: root, area: domain.Rect{Width: 61, Height: 5}, focus: "left", placements: placements, fingerprint: layoutFingerprint(root), valid: true},
151+ panes: panes, styles: resolveStyles(nil),
152+ }
153+ }
154+
155+ initial := stateFor(true, []renderer.Damage{renderer.FullRedraw()})
156+ committed := composeFrame(initial, composeCacheInput{})
157+ root.Children[0].Weight = 2
158+ next := stateFor(false, nil)
159+ require.NotEqual(t, initial.layout.fingerprint, next.layout.fingerprint)
160+ require.Equal(t, 41, next.layout.placements[1].Content.X)
161+ out := composeFrame(next, committed.cache, composeCacheInput{})
162+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
163+ require.Equal(t, 'l', out.frame.At(31, 1).Rune, "the enlarged pane must replace its old divider/cache footprint")
164+ require.Equal(t, 'r', out.frame.At(41, 1).Rune, "weight-derived pane geometry must be reblitted at its new position")
165+}
166+
128 func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {167 func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {
129 theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}168 theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}
130 neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}}169 neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}}
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+}
A internal/usecase/daemon/resize_mode_test.go +415 -0
@@ -0,0 +1,415 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ scopy "github.com/bnema/vev/internal/usecase/copy"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/internal/usecase/mouse"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestPaletteAndControlShareResizeAction(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ palette func(paletteExec) error
19+ control func(controlExec) error
20+ kind daemonActionKind
21+ axis layout.Axis
22+ delta int
23+ }{
24+ {"grow width", func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }, daemonActionResizePane, layout.Width, 2},
25+ {"shrink width", func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }, daemonActionResizePane, layout.Width, -2},
26+ {"grow height", func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }, daemonActionResizePane, layout.Height, 1},
27+ {"shrink height", func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }, daemonActionResizePane, layout.Height, -1},
28+ {"equalize", func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }, daemonActionEqualizePanes, layout.Width, 0},
29+ }
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ p := &actionRunnerSpy{}
33+ c := &actionRunnerSpy{}
34+ require.NoError(t, tt.palette(paletteExec{actions: p}))
35+ require.NoError(t, tt.control(controlExec{actions: c}))
36+ require.Len(t, p.requests, 1)
37+ require.Equal(t, p.requests, c.requests)
38+ require.Equal(t, tt.kind, p.requests[0].kind)
39+ require.Equal(t, tt.axis, p.requests[0].axis)
40+ require.Equal(t, tt.delta, p.requests[0].delta)
41+ })
42+ }
43+}
44+
45+func TestControlEqualizeMapsResizeErrors(t *testing.T) {
46+ runner := &actionRunnerSpy{err: layout.ErrTooSmall}
47+ err := (controlExec{actions: runner}).EqualizePanes()
48+ require.ErrorIs(t, err, layout.ErrTooSmall)
49+ var userErr *domain.UserError
50+ require.ErrorAs(t, err, &userErr)
51+ require.Len(t, runner.requests, 1)
52+ require.Equal(t, daemonActionEqualizePanes, runner.requests[0].kind)
53+}
54+
55+func TestResizeControlHeadlessAndErrors(t *testing.T) {
56+ factory := &controlPTYFactory{}
57+ d := newTestDaemon(t, factory, stubClock{})
58+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
59+ sess := addControlSession(d, "work", "t_work", "p_work")
60+
61+ tooEarly := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
62+ require.False(t, tooEarly.OK)
63+ require.Equal(t, ports.ErrNoSuchTarget, tooEarly.Code)
64+ require.Equal(t, "pane is not in a split", tooEarly.Text)
65+
66+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
67+ tb := sess.activeTab()
68+ tb.mu.Lock()
69+ beforeFocus := tb.tree.Focus
70+ beforeGeneration := tb.layoutGeneration
71+ var target *pane
72+ for id, candidate := range tb.panes {
73+ if id != beforeFocus {
74+ target = candidate
75+ break
76+ }
77+ }
78+ tb.mu.Unlock()
79+ require.NotNil(t, target)
80+
81+ require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
82+ kind: daemonActionResizePane,
83+ target: daemonActionTarget{session: sess, tab: tb, pane: target},
84+ axis: layout.Width,
85+ delta: resizeStepCols,
86+ }))
87+ tb.mu.Lock()
88+ require.Equal(t, beforeFocus, tb.tree.Focus, "exact pane targeting must not refocus the tab")
89+ require.Equal(t, beforeGeneration+1, tb.layoutGeneration)
90+ tb.mu.Unlock()
91+
92+ equalized := sendCommand(t, d, ports.CommandRequest{Slug: "equalize-panes", TargetSession: "work"})
93+ require.True(t, equalized.OK, equalized.Text)
94+ require.Empty(t, equalized.Output)
95+}
96+
97+func TestEnterResizeModeRejectsPaneOutsideSplit(t *testing.T) {
98+ factory := &controlPTYFactory{}
99+ d := newTestDaemon(t, factory, stubClock{})
100+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
101+ sess := addControlSession(d, "work", "t_work", "p_work")
102+ ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
103+ ac.initOverlays()
104+ ac.setSession(sess)
105+
106+ err := d.enterResizeMode(sess, ac)
107+ require.ErrorIs(t, err, layout.ErrNotInSplit)
108+ require.False(t, ac.overlays.resizeModeActive())
109+}
110+
111+func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) {
112+ factory := &controlPTYFactory{}
113+ d := newTestDaemon(t, factory, stubClock{})
114+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
115+ sess := addControlSession(d, "work", "t_work", "p_work")
116+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
117+
118+ ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
119+ ac.initOverlays()
120+ ac.setSession(sess)
121+ require.NoError(t, d.enterResizeMode(sess, ac))
122+ require.True(t, ac.overlays.resizeModeActive())
123+ d.handleResizeInput(ac, []byte("\x1b"))
124+ require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix")
125+ d.handleResizeInput(ac, []byte("[D"))
126+ require.True(t, ac.overlays.resizeModeActive())
127+
128+ snapshot := ac.overlays.SnapshotForRender()
129+ require.True(t, snapshot.resizeActive)
130+ snapshot.Unlock()
131+ require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer")
132+
133+ tb := sess.activeTab()
134+ tb.mu.Lock()
135+ focused := tb.focusedPane()
136+ tb.mu.Unlock()
137+ focused.mu.Lock()
138+ cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true})
139+ focused.mu.Unlock()
140+ require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live")
141+
142+ ac.overlays.copyMu.Lock()
143+ ac.overlays.copyPointer.valid = true
144+ ac.overlays.copyMu.Unlock()
145+ d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
146+ ac.overlays.copyMu.Lock()
147+ require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing")
148+ ac.overlays.copyMu.Unlock()
149+
150+ ac.overlays.copyMu.Lock()
151+ ac.overlays.copyMode = &scopy.Mode{}
152+ ac.overlays.copyMu.Unlock()
153+ require.True(t, ac.overlays.HandleInput(d, []byte("q")))
154+ require.False(t, ac.overlays.resizeModeActive())
155+ require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode")
156+}
157+
158+func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
159+ var left, right int
160+ pending := []byte(nil)
161+ events := overlayEvents{left: func() { left++ }, right: func() { right++ }}
162+ routeOverlayBytes([]byte("\x1b["), &pending, events)
163+ require.Equal(t, []byte("\x1b["), pending)
164+ routeOverlayBytes([]byte("D\x1bOC"), &pending, events)
165+ require.Equal(t, 1, left)
166+ require.Equal(t, 1, right)
167+ require.Empty(t, pending)
168+}
169+
170+// TestResizeModeKeysAndEscapes exercises the modal parser through the real
171+// action seam. Every directional spelling must commit exactly one mutation;
172+// parser prefixes are deliberately split across reads.
173+func TestResizeModeKeysAndEscapes(t *testing.T) {
174+ tests := []struct {
175+ name string
176+ chunks [][]byte
177+ change bool
178+ exit bool
179+ equalize bool
180+ }{
181+ {"h", [][]byte{[]byte("h")}, true, false, false},
182+ {"j", [][]byte{[]byte("j")}, true, false, false},
183+ {"k", [][]byte{[]byte("k")}, true, false, false},
184+ {"l", [][]byte{[]byte("l")}, true, false, false},
185+ {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false, false},
186+ {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false, false},
187+ {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false, false},
188+ {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false, false},
189+ {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false, false},
190+ {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false, false},
191+ {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false, false},
192+ {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false, false},
193+ {"equalize", [][]byte{[]byte("=")}, true, false, true},
194+ {"ignored", [][]byte{[]byte("z\x00")}, false, false, false},
195+ {"q exits", [][]byte{[]byte("q")}, false, true, false},
196+ {"enter exits", [][]byte{[]byte("\r")}, false, true, false},
197+ {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true, false},
198+ }
199+ for _, tt := range tests {
200+ t.Run(tt.name, func(t *testing.T) {
201+ d, sess, ac := resizeModeFixture(t)
202+ tb := sess.activeTab()
203+ tb.mu.Lock()
204+ before := tb.layoutGeneration
205+ if tt.equalize {
206+ tb.tree.Root.Children[0].Weight = 3
207+ }
208+ tb.mu.Unlock()
209+ for _, chunk := range tt.chunks {
210+ d.handleResizeInput(ac, chunk)
211+ }
212+ tb.mu.Lock()
213+ after := tb.layoutGeneration
214+ if tt.equalize {
215+ for _, child := range tb.tree.Root.Children {
216+ require.Zero(t, child.Weight)
217+ }
218+ }
219+ tb.mu.Unlock()
220+ if tt.change {
221+ require.Equal(t, before+1, after)
222+ } else {
223+ require.Equal(t, before, after)
224+ }
225+ require.Equal(t, !tt.exit, ac.overlays.resizeModeActive())
226+ })
227+ }
228+}
229+
230+func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
231+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
232+ tb := sess.activeTab()
233+ second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
234+ tb.mu.Lock()
235+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
236+ tb.panes[second.id] = second
237+ tb.mu.Unlock()
238+ second.screen.Write([]byte("live resize content"))
239+ require.NoError(t, d.enterResizeMode(sess, ac))
240+ data := string(mustOutputData(t, sends))
241+ require.Contains(t, data, "resize: h/j/k/l or arrows")
242+ require.Contains(t, data, "live resize content")
243+ require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")
244+}
245+
246+func TestResizeModeOverlayPriority(t *testing.T) {
247+ tests := []struct {
248+ name string
249+ input []byte
250+ setup func(*Daemon, *session, *attachedClient)
251+ check func(*testing.T, *attachedClient)
252+ }{
253+ {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
254+ d.enterCopyMode(sess, ac)
255+ d.enterNotices(sess, ac)
256+ d.enterPicker(sess, ac)
257+ d.enterPalette(sess, ac)
258+ d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil })
259+ }, func(t *testing.T, ac *attachedClient) {
260+ require.True(t, ac.overlays.promptActive())
261+ require.True(t, ac.overlays.resizeModeActive())
262+ }},
263+ {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
264+ d.enterCopyMode(sess, ac)
265+ d.enterPicker(sess, ac)
266+ d.enterPalette(sess, ac)
267+ }, func(t *testing.T, ac *attachedClient) {
268+ require.True(t, ac.overlays.paletteActive())
269+ require.True(t, ac.overlays.resizeModeActive())
270+ }},
271+ {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) {
272+ d.enterCopyMode(sess, ac)
273+ d.enterPicker(sess, ac)
274+ }, func(t *testing.T, ac *attachedClient) {
275+ require.True(t, ac.overlays.pickerActive())
276+ require.True(t, ac.overlays.resizeModeActive())
277+ }},
278+ {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
279+ d.enterCopyMode(sess, ac)
280+ d.enterNotices(sess, ac)
281+ }, func(t *testing.T, ac *attachedClient) {
282+ require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices")
283+ require.True(t, ac.overlays.resizeModeActive())
284+ }},
285+ }
286+ for _, tt := range tests {
287+ t.Run(tt.name, func(t *testing.T) {
288+ d, sess, ac := resizeModeFixture(t)
289+ tt.setup(d, sess, ac)
290+ require.True(t, ac.overlays.HandleInput(d, tt.input))
291+ tt.check(t, ac)
292+ })
293+ }
294+}
295+
296+func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) {
297+ t.Run("too small warning leaves mode active", func(t *testing.T) {
298+ d, sess, ac := resizeModeFixture(t)
299+ tb := sess.activeTab()
300+ tb.mu.Lock()
301+ tb.size.Cols = 41 // two minimum-width panes and their separator
302+ tb.bumpLayoutGenerationLocked()
303+ tb.mu.Unlock()
304+ d.handleResizeInput(ac, []byte("l"))
305+ require.True(t, ac.overlays.resizeModeActive())
306+ history := d.notices.history()
307+ require.NotEmpty(t, history)
308+ require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message)
309+ })
310+
311+ t.Run("palette control and modal have the same committed weights", func(t *testing.T) {
312+ type invoke func(*Daemon, *session, *attachedClient) error
313+ invocations := []struct {
314+ name string
315+ run invoke
316+ }{
317+ {"palette", func(d *Daemon, sess *session, ac *attachedClient) error {
318+ return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth()
319+ }},
320+ {"control", func(d *Daemon, sess *session, _ *attachedClient) error {
321+ target := resolveDaemonActionTarget(sess)
322+ return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth()
323+ }},
324+ {"modal", func(d *Daemon, _ *session, ac *attachedClient) error {
325+ d.handleResizeInput(ac, []byte("l"))
326+ return nil
327+ }},
328+ }
329+ var want []float64
330+ for _, tt := range invocations {
331+ t.Run(tt.name, func(t *testing.T) {
332+ d, sess, ac := resizeModeFixture(t)
333+ require.NoError(t, tt.run(d, sess, ac))
334+ tb := sess.activeTab()
335+ tb.mu.Lock()
336+ got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight}
337+ tb.mu.Unlock()
338+ if want == nil {
339+ want = got
340+ } else {
341+ require.Equal(t, want, got)
342+ }
343+ })
344+ }
345+ })
346+}
347+
348+type resizeLockProbePTY struct {
349+ once sync.Once
350+ entered chan struct{}
351+ release chan struct{}
352+ check func()
353+}
354+
355+func (p *resizeLockProbePTY) Read([]byte) (int, error) { return 0, nil }
356+func (*resizeLockProbePTY) Write(b []byte) (int, error) { return len(b), nil }
357+func (*resizeLockProbePTY) Close() error { return nil }
358+func (*resizeLockProbePTY) Pid() int { return 0 }
359+func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil }
360+func (p *resizeLockProbePTY) Resize(domain.Size) error {
361+ p.once.Do(func() {
362+ func() {
363+ defer close(p.entered)
364+ p.check()
365+ }()
366+ <-p.release
367+ })
368+ return nil
369+}
370+
371+func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
372+ // Build a live tree directly so the probe is not concurrently owned by a
373+ // pane reader; this isolates the lock boundary being asserted.
374+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
375+ tb := sess.activeTab()
376+ probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})}
377+ second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23})
378+ tb.mu.Lock()
379+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
380+ tb.panes[second.id] = second
381+ tb.mu.Unlock()
382+ probe.check = func() {
383+ if tb.mu.TryLock() {
384+ tb.mu.Unlock()
385+ } else {
386+ t.Error("PTY callback must not inherit tab.mu")
387+ }
388+ if ac.overlays.resizeMu.TryLock() {
389+ ac.overlays.resizeMu.Unlock()
390+ } else {
391+ t.Error("PTY callback must not inherit resize overlay mutex")
392+ }
393+ }
394+ require.NoError(t, d.enterResizeMode(sess, ac))
395+ done := make(chan struct{})
396+ go func() { d.handleResizeInput(ac, []byte("l")); close(done) }()
397+ <-probe.entered
398+ close(probe.release)
399+ <-done
400+}
401+
402+func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) {
403+ t.Helper()
404+ factory := &controlPTYFactory{}
405+ d := newTestDaemon(t, factory, stubClock{})
406+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
407+ sess := addControlSession(d, "work", "t_work", "p_work")
408+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
409+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
410+ ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
411+ ac.initOverlays()
412+ ac.setSession(sess)
413+ require.NoError(t, d.enterResizeMode(sess, ac))
414+ return d, sess, ac
415+}
M internal/usecase/daemon/resume_test.go +2 -1
@@ -372,8 +372,9 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
372 tb.mu.Lock()372 tb.mu.Lock()
373 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}373 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
374 tb.panes[closed.id] = closed374 tb.panes[closed.id] = closed
375- d.applyLayoutLocked(tb)375+ tb.bumpLayoutGenerationLocked()
376 tb.mu.Unlock()376 tb.mu.Unlock()
377+ d.applyTabLayout(sess, tb)
377 survivor.screen.Write([]byte("survivor"))378 survivor.screen.Write([]byte("survivor"))
378 closed.screen.Write([]byte("closed"))379 closed.screen.Write([]byte("closed"))
379 d.paint(sess, ac, true, nil)380 d.paint(sess, ac, true, nil)
M internal/usecase/daemon/session.go +19 -0
@@ -105,6 +105,10 @@ type session struct {
105 syncGen atomic.Uint64105 syncGen atomic.Uint64
106 // coordinator fans in this session's producer render invalidations.106 // coordinator fans in this session's producer render invalidations.
107 coordinator atomic.Pointer[renderCoordinator]107 coordinator atomic.Pointer[renderCoordinator]
108+ // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish
109+ // transactions. It is deliberately not an architecture lock and may span
110+ // external PTY.Resize calls.
111+ layoutApplyMu sync.Mutex
108 // clipFiles records clipboard-image-transfer temp file paths (see112 // clipFiles records clipboard-image-transfer temp file paths (see
109 // clipboard.go) written for this session, removed best-effort in113 // clipboard.go) written for this session, removed best-effort in
110 // killSession.114 // killSession.
@@ -122,6 +126,17 @@ type session struct {
122 type tab struct {126 type tab struct {
123 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership127 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
124 128
129+ // layoutGeneration invalidates prepared geometry whenever live tab layout
130+ // state changes. layoutApplyMu serializes the lock-free PTY apply boundary.
131+ layoutGeneration uint64
132+ layoutApplyMu sync.Mutex
133+ // layoutRetryMu owns the one bounded delayed retry worker for accepted
134+ // tiled-layout degradation. Its context is derived from ctx, so tab/session
135+ // teardown cancels a waiting retry before it can touch PTY state.
136+ layoutRetryMu sync.Mutex
137+ layoutRetryCancel context.CancelFunc
138+ layoutRetryRunning bool
139+
125 stableID string140 stableID string
126 name string141 name string
127 tree *layout.Tree142 tree *layout.Tree
@@ -435,6 +450,10 @@ func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz doma
435 }450 }
436 }451 }
437 452
453+func (tb *tab) bumpLayoutGenerationLocked() {
454+ tb.layoutGeneration++
455+}
456+
438 func (tb *tab) focusedPane() *pane {457 func (tb *tab) focusedPane() *pane {
439 if tb == nil || tb.tree == nil || tb.panes == nil {458 if tb == nil || tb.tree == nil || tb.panes == nil {
440 return nil459 return nil
M internal/usecase/daemon/transactional_resize.go +599 -153
@@ -1,6 +1,8 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
6 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
@@ -9,116 +11,303 @@ import (
9 // resizeMember is an unpublished layout decision. Prepare only reads guarded11 // resizeMember is an unpublished layout decision. Prepare only reads guarded
10 // state; apply performs the external PTY call; commit is the sole publisher.12 // state; apply performs the external PTY call; commit is the sole publisher.
11 type resizeMember struct {13 type resizeMember struct {
12- session *session14+ session *session
13- tab *tab15+ tab *tab
14- pane *pane16+ pane *pane
15- rect domain.Rect17+ rect domain.Rect
16- floating floatingGeometry18+ floating floatingGeometry
17- isFloating bool19+ isFloating bool
18- retry bool20+ // floatingGeneration identifies the exact popup slot accepted by a failed
19- ok bool21+ // resize. Floating panes are intentionally outside tb.panes, so retries
20- screenResized bool22+ // must validate this generation and pointer rather than use tiled lookup.
23+ floatingGeneration uint64
24+ retry bool
25+ ok bool
26+ err error
27+ screenResized bool
21 }28 }
22 29
23-type resizePlan struct {30+type preparedTabLayout struct {
24- size domain.Size31+ tab *tab
25- tabs []*tab32+ generation uint64
26- members []resizeMember33+ size domain.Size
34+ previousSize domain.Size
35+ members []resizeMember
27 }36 }
28 37
29-func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {38+func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
30- plan := resizePlan{size: size}39+ return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
31- sess.mu.Lock()40+}
32- plan.tabs = append(plan.tabs, sess.tabs...)41+
33- active := sess.active42+func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) preparedTabLayout {
34- sess.mu.Unlock()43+ plan := preparedTabLayout{tab: tb, generation: tb.layoutGeneration, size: size, previousSize: tb.size}
35- for i, tb := range plan.tabs {44+ if tb.tree == nil || tb.tree.Root == nil || !size.Valid() {
36- tb.mu.Lock()45+ return plan
37- if tb.tree != nil && tb.tree.Root != nil {46+ }
38- ts := tabSize(size)47+ area := domain.Rect{Width: size.Cols, Height: size.Rows}
39- area := domain.Rect{Width: ts.Cols, Height: ts.Rows}48+ placements, ok := layout.Solve(tb.tree.Root, area)
40- if placements, ok := layout.Solve(tb.tree.Root, area); ok {49+ if !ok && tb.tree.Root.Kind == layout.Leaf {
41- for _, pl := range placements {50+ placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}}
42- if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {51+ ok = true
43- if p := tb.panes[pl.ID]; p != nil {52+ }
44- plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: pl.Content})53+ if !ok {
45- }54+ return plan
46- }55+ }
47- }56+ for _, placement := range placements {
48- } else if tb.tree.Root.Kind == layout.Leaf {57+ if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
49- // A single pane remains a valid PTY target even when a tiny client58+ continue
50- // is below the interactive layout minimum. Keeping it live avoids
51- // stale screen dimensions (and lost scrollback) until the client
52- // grows back into a solvable layout.
53- if p := tb.panes[tb.tree.Root.Leaf]; p != nil {
54- plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: area})
55- }
56- }
57 }59 }
58- // Hidden retained popups enter this same primitive when shown; only a60+ if p := tb.panes[placement.ID]; p != nil {
59- // visible popup participates in an unrelated client resize.61+ plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: placement.Content})
60- if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
61- g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
62- if g.valid() {
63- plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true})
64- }
65 }62 }
66- tb.mu.Unlock()
67 }63 }
68 return plan64 return plan
69 }65 }
70 66
71-// applyResize deliberately holds only a pane's resizeMu around its PTY call;67+// applyPreparedTabMembers performs only the external PTY phase. A successful
72-// no daemon, session, tab, or pane lock crosses the external boundary.68+// resize leaves the parser gate closed until the complete plan is validated.
73-func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {69+func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
74- // Collect degradation across the loop and report once from the explicit
75- // lock-free return points. notify acquires sess.mu/d.mu and repaints, so it
76- // must never run while a pane's resizeMu is held.
77- failed := 0
78- var lastErr error
79- reportFailure := func() {
80- if failed == 0 {
81- return
82- }
83- // A failure can only come from a plan member, so members[0] is safe.
84- d.notify(plan.members[0].session, domain.NoticeWarn, domain.NoticeResizeFailed,
85- "pane resize failed; retrying in background", lastErr)
86- }
87 for i := range plan.members {70 for i := range plan.members {
88- if len(current) != 0 && !current[0]() {71+ member := &plan.members[i]
89- reportFailure()72+ p := member.pane
90- return false73+ p.resizeMu.Lock()
91- }74+ p.mu.Lock()
92- m := &plan.members[i]75+ old := p.rect
93- m.pane.resizeMu.Lock()76+ pty := p.pty
94- m.pane.mu.Lock()77+ // A rejected earlier attempt deliberately leaves its parser gate open.
95- old, pty := m.pane.rect, m.pane.pty78+ // Reapply even if the committed rectangle already matches: the PTY may
96- screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height}79+ // still be at that rejected attempt's external size. An accepted failed
97- needsApply := m.retry || old.Width != m.rect.Width || old.Height != m.rect.Height || screenSize != rectSize(m.rect)80+ // attempt records resizeRetry for the same reason after its gate closes.
81+ needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height
98 if needsApply && pty != nil {82 if needsApply && pty != nil {
99- // Publish this gate before the external call. ptyReader keeps draining83+ p.resizeApplying = true
100- // but does not parse bytes at the stale width while Resize is in flight.
101- m.pane.resizeApplying = true
102 }84 }
103- m.pane.mu.Unlock()85+ p.mu.Unlock()
104 if !needsApply || pty == nil {86 if !needsApply || pty == nil {
105- m.ok = true87+ member.ok = true
106- } else if err := pty.Resize(rectSize(m.rect)); err != nil {88+ } else if err := pty.Resize(rectSize(member.rect)); err != nil {
107 d.log.Warn("pty resize failed", "err", err)89 d.log.Warn("pty resize failed", "err", err)
108- failed++90+ p.mu.Lock()
109- lastErr = err91+ p.resizeRetry = true
110- d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)92+ p.mu.Unlock()
93+ d.replayResizePending(member.session, member.tab, p, false, member.rect)
94+ member.retry = true
95+ member.err = err
111 } else {96 } else {
112- m.ok = true97+ member.ok = true
113- d.replayResizePending(m.session, m.tab, m.pane, true, m.rect)98+ member.screenResized = true // records that this member owns an open gate
114- m.screenResized = true99+ }
100+ p.resizeMu.Unlock()
101+ }
102+}
103+
104+func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
105+ if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize {
106+ return false
107+ }
108+ for i := range plan.members {
109+ member := &plan.members[i]
110+ if tb.panes[member.pane.id] != member.pane {
111+ return false
112+ }
113+ }
114+ return true
115+}
116+
117+func commitPreparedTabLayoutLocked(plan *preparedTabLayout) {
118+ for i := range plan.members {
119+ member := &plan.members[i]
120+ member.pane.mu.Lock()
121+ member.pane.rect = member.rect
122+ if member.ok {
123+ member.pane.resizeRetry = false
124+ member.pane.screen.Resize(member.rect.Width, member.rect.Height)
125+ }
126+ member.pane.mu.Unlock()
127+ }
128+}
129+
130+// cancelStalePreparedGates releases gates for members which disappeared from
131+// the next solved layout (for example a stack member collapsed by a focus
132+// change). Members that remain are deliberately left gated for the retry.
133+func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *preparedTabLayout) {
134+ tb.mu.Lock()
135+ latest := prepareTabLayoutLocked(sess, tb)
136+ tb.mu.Unlock()
137+ current := make(map[*pane]struct{}, len(latest.members))
138+ for i := range latest.members {
139+ current[latest.members[i].pane] = struct{}{}
140+ }
141+ for i := range plan.members {
142+ member := &plan.members[i]
143+ if !member.screenResized {
144+ continue
145+ }
146+ if _, ok := current[member.pane]; ok {
147+ continue
115 }148 }
116- m.pane.resizeMu.Unlock()149+ member.pane.resizeMu.Lock()
150+ d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
151+ member.pane.resizeMu.Unlock()
152+ }
153+}
154+
155+func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool) {
156+ if !accepted {
157+ // cancelStalePreparedGates owns stale-plan cancellation. Keeping this
158+ // path empty ensures a removed member's pending bytes are replayed once,
159+ // after the fresh layout determines it cannot survive the retry.
160+ return
161+ }
162+ for i := range plan.members {
163+ member := &plan.members[i]
164+ if !member.screenResized {
165+ continue
166+ }
167+ member.pane.resizeMu.Lock()
168+ d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
169+ member.pane.resizeMu.Unlock()
170+ }
171+}
172+
173+// applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
174+// one per-tab single-writer loop, but never holds tab or pane state locks while
175+// invoking PTY.Resize.
176+func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) {
177+ return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)
178+}
179+
180+func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) {
181+ if tb == nil {
182+ return nil, false
183+ }
184+ tb.layoutApplyMu.Lock()
185+ defer tb.layoutApplyMu.Unlock()
186+ for {
187+ if tb.ctx != nil && tb.ctx.Err() != nil {
188+ return nil, false
189+ }
190+ if len(current) != 0 && !current[0]() {
191+ return nil, false
192+ }
193+ tb.mu.Lock()
194+ plan := prepareTabLayoutLocked(sess, tb)
195+ tb.mu.Unlock()
196+ d.applyPreparedTabMembers(&plan)
197+
198+ tb.mu.Lock()
199+ accepted := validatePreparedTabLayoutLocked(tb, &plan)
200+ if len(current) != 0 && !current[0]() {
201+ accepted = false
202+ }
203+ if accepted {
204+ commitPreparedTabLayoutLocked(&plan)
205+ }
206+ tb.mu.Unlock()
207+ d.finishPreparedTabMembers(&plan, accepted)
208+ if !accepted {
209+ d.cancelStalePreparedGates(sess, tb, &plan)
210+ if len(current) != 0 && !current[0]() {
211+ for i := range plan.members {
212+ member := &plan.members[i]
213+ if !member.screenResized {
214+ continue
215+ }
216+ member.pane.resizeMu.Lock()
217+ d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
218+ member.pane.resizeMu.Unlock()
219+ }
220+ return nil, false
221+ }
222+ continue
223+ }
224+ failed := make([]resizeMember, 0)
225+ for _, member := range plan.members {
226+ if !member.ok {
227+ failed = append(failed, member)
228+ }
229+ }
230+ if reportFailure && len(failed) != 0 {
231+ d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
232+ "pane resize failed; retrying in background", failed[len(failed)-1].err)
233+ }
234+ return failed, true
235+ }
236+}
237+
238+func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
239+ failed, ok := d.applyTabLayoutTransaction(sess, tb)
240+ if !ok {
241+ return false
242+ }
243+ markSnapshotDirty(sess)
244+ if len(failed) != 0 {
245+ d.scheduleAcceptedTabLayoutRetry(sess, tb)
117 }246 }
118- reportFailure()
119 return true247 return true
120 }248 }
121 249
250+const maxAcceptedTabLayoutRetries = 3
251+
252+// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
253+// worker is deduplicated, derives cancellation from the tab lifecycle, and
254+// suppresses repeat degradation notices after the accepted initial failure.
255+func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
256+ if d.clock == nil || tb == nil || tb.ctx == nil {
257+ return
258+ }
259+ tb.layoutRetryMu.Lock()
260+ if tb.layoutRetryRunning {
261+ tb.layoutRetryMu.Unlock()
262+ return
263+ }
264+ ctx, cancel := context.WithCancel(tb.ctx)
265+ tb.layoutRetryRunning = true
266+ tb.layoutRetryCancel = cancel
267+ tb.layoutRetryMu.Unlock()
268+
269+ go func() {
270+ defer func() {
271+ cancel()
272+ tb.layoutRetryMu.Lock()
273+ tb.layoutRetryRunning = false
274+ tb.layoutRetryCancel = nil
275+ tb.layoutRetryMu.Unlock()
276+ }()
277+ for range maxAcceptedTabLayoutRetries {
278+ timer := d.clock.NewTimer(minOutputRenderDeadline)
279+ if timer == nil {
280+ return
281+ }
282+ timerC := timer.C()
283+ if timerC == nil {
284+ timer.Stop()
285+ return
286+ }
287+ select {
288+ case <-ctx.Done():
289+ timer.Stop()
290+ return
291+ case <-timerC:
292+ }
293+ if ctx.Err() != nil {
294+ return
295+ }
296+ failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil })
297+ if !ok || len(failed) == 0 {
298+ return
299+ }
300+ markSnapshotDirty(sess)
301+ sess.mu.Lock()
302+ ac := sess.client
303+ sess.mu.Unlock()
304+ if ac != nil {
305+ d.invalidateRender(sess, ac, true, "transactional_resize.go")
306+ }
307+ }
308+ }()
309+}
310+
122 // replayResizePending completes a pane's apply epoch. It keeps the gate set311 // replayResizePending completes a pane's apply epoch. It keeps the gate set
123 // while each buffered batch goes through the normal VT path, so reads arriving312 // while each buffered batch goes through the normal VT path, so reads arriving
124 // during replay join the same ordered stream. Failure deliberately omits the313 // during replay join the same ordered stream. Failure deliberately omits the
@@ -150,36 +339,217 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo
150 }339 }
151 }340 }
152 341
153-func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {342+// applyVisibleFloatingLayout retains floating panes' existing independent
154- // Publish tab layout and every rectangle together only after the coordinator343+// lifecycle while routing their external resize through the same lock-free gate
155- // has rejected obsolete work. A later request may have applied already, but344+// used for retryable PTY work. Floating state is not part of a tiled layout
156- // it can never publish through this epoch.345+// generation, so its slot generation and exact pane identity validate its
157- for _, tb := range plan.tabs {346+// publication instead.
158- tb.mu.Lock()347+func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) {
159- tb.size = tabSize(plan.size)348+ return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)
349+}
350+
351+// applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
352+// exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
353+// so accepting a replacement here would silently retry unrelated geometry.
354+func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) {
355+ if tb == nil {
356+ return nil, true
357+ }
358+ tb.mu.Lock()
359+ if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
360+ (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
160 tb.mu.Unlock()361 tb.mu.Unlock()
362+ return nil, true
363+ }
364+ p := tb.floating.pane
365+ generation := tb.floating.generation
366+ size := tb.size
367+ geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
368+ tb.mu.Unlock()
369+ if !geometry.valid() {
370+ return nil, true
371+ }
372+
373+ // This keeps successful PTY resizes gated until the
374+ // floating slot and tab size are revalidated. A newer client resize may
375+ // otherwise publish this obsolete popup geometry after its PTY call returns.
376+ plan := preparedTabLayout{members: []resizeMember{{
377+ session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,
378+ }}}
379+ d.applyPreparedTabMembers(&plan)
380+
381+ // The PTY may have accepted an intermediate size, but a hidden, replaced,
382+ // relaunched, or resized slot must never receive this attempt's geometry.
383+ tb.mu.Lock()
384+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size
385+ if current != nil && !current() {
386+ currentSlot = false
161 }387 }
162- for _, m := range plan.members {388+ if currentSlot {
163- // Layout geometry always publishes together. Failed members deliberately389+ p.mu.Lock()
164- // retain their old VT screen; capture clips that screen to this new rect390+ p.rect = geometry.Inner
165- // and pads the uncovered cells with normal blanks.391+ p.popupGeometry = geometry
166- m.pane.mu.Lock()392+ if plan.members[0].ok {
167- m.pane.rect = m.rect393+ p.resizeRetry = false
168- if m.isFloating {394+ p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
169- m.pane.popupGeometry = m.floating
170 }395 }
171- if m.ok && !m.screenResized {396+ p.mu.Unlock()
172- m.pane.screen.Resize(m.rect.Width, m.rect.Height)397+ }
398+ tb.mu.Unlock()
399+ if !currentSlot {
400+ // This attempt has no tiled transaction loop to retain the gate for, so
401+ // discard its buffered bytes at the old screen size before returning.
402+ for i := range plan.members {
403+ member := &plan.members[i]
404+ if !member.screenResized {
405+ continue
406+ }
407+ member.pane.resizeMu.Lock()
408+ d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
409+ member.pane.resizeMu.Unlock()
410+ }
411+ if current != nil && !current() {
412+ return nil, false
173 }413 }
174- m.pane.mu.Unlock()414+ return nil, true
175 }415 }
176- if ac != nil {416+ if plan.members[0].ok {
177- ac.sendMu.Lock()417+ d.finishPreparedTabMembers(&plan, true)
178- ac.size = plan.size418+ return nil, true
179- ac.sendMu.Unlock()419+ }
420+ return plan.members, true
421+}
422+
423+// releasePreparedSessionGates abandons a session attempt which cannot be
424+// retried here (for example because a newer coordinator epoch won). Unlike a
425+// stale per-tab retry, no succeeding attempt is owned by this call, so every
426+// successful external resize must reopen its parser gate at the old screen
427+// size.
428+func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
429+ for _, plan := range plans {
430+ if plan == nil {
431+ continue
432+ }
433+ for i := range plan.members {
434+ member := &plan.members[i]
435+ if !member.screenResized {
436+ continue
437+ }
438+ member.pane.resizeMu.Lock()
439+ d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
440+ member.pane.resizeMu.Unlock()
441+ }
442+ }
443+}
444+
445+// applySessionLayout keeps client resize publication as a two-phase session
446+// transaction: all PTYs are applied and plans validated first, then the
447+// coordinator admits the epoch before any tab size, rectangle, VT screen,
448+// snapshot dirtiness, or resize telemetry becomes visible.
449+func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
450+ if sess == nil {
451+ return nil, false
452+ }
453+ sess.layoutApplyMu.Lock()
454+ defer sess.layoutApplyMu.Unlock()
455+
456+ target := tabSize(size)
457+ for {
458+ if sess.ctx != nil && sess.ctx.Err() != nil {
459+ return nil, false
460+ }
461+ if current != nil && !current() {
462+ return nil, false
463+ }
464+ sess.mu.Lock()
465+ tabs := append([]*tab(nil), sess.tabs...)
466+ active := sess.active
467+ sess.mu.Unlock()
468+ plans := make([]*preparedTabLayout, 0, len(tabs))
469+ for _, tb := range tabs {
470+ tb.mu.Lock()
471+ plan := prepareTabLayoutForSizeLocked(sess, tb, target)
472+ tb.mu.Unlock()
473+ plans = append(plans, &plan)
474+ }
475+ for _, plan := range plans {
476+ d.applyPreparedTabMembers(plan)
477+ }
478+
479+ // Hold every tab lock across final validation, epoch admission, and all
480+ // publication. This makes the session commit indivisible to layout
481+ // mutators while still keeping PTY.Resize outside every state lock.
482+ for _, tb := range tabs {
483+ tb.mu.Lock()
484+ }
485+ valid := true
486+ for _, plan := range plans {
487+ if !validatePreparedTabLayoutLocked(plan.tab, plan) {
488+ valid = false
489+ break
490+ }
491+ }
492+ // This is the final external-apply validation boundary. Tests use the
493+ // seam to install a newer epoch here; that epoch must reject admission
494+ // before this attempt publishes any session geometry.
495+ if valid && d.beforeSessionResizePublication != nil {
496+ d.beforeSessionResizePublication()
497+ }
498+ if valid && sess.ctx != nil && sess.ctx.Err() != nil {
499+ valid = false
500+ }
501+ if valid && current != nil && !current() {
502+ valid = false
503+ }
504+ if valid && admit != nil && !admit() {
505+ valid = false
506+ }
507+ if valid {
508+ for _, plan := range plans {
509+ plan.tab.size = plan.size
510+ commitPreparedTabLayoutLocked(plan)
511+ }
512+ }
513+ for i := len(tabs) - 1; i >= 0; i-- {
514+ tabs[i].mu.Unlock()
515+ }
516+ if !valid {
517+ d.releasePreparedSessionGates(plans)
518+ // Headless requests have no attachment epoch. A live tab mutation
519+ // still invalidates their plan, but must trigger a fresh prepare rather
520+ // than being mistaken for a canceled request.
521+ if current != nil && !current() {
522+ return nil, false
523+ }
524+ // A layout mutation invalidated the plans while this epoch remains
525+ // current. Reapply the fresh session geometry before admitting it.
526+ continue
527+ }
528+
529+ failed := make([]resizeMember, 0)
530+ for _, plan := range plans {
531+ d.finishPreparedTabMembers(plan, true)
532+ for _, member := range plan.members {
533+ if !member.ok {
534+ failed = append(failed, member)
535+ }
536+ }
537+ }
538+ if active >= 0 && active < len(tabs) {
539+ floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
540+ if !ok {
541+ return nil, false
542+ }
543+ failed = append(failed, floatingFailed...)
544+ }
545+ if len(failed) != 0 {
546+ d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
547+ "pane resize failed; retrying in background", failed[len(failed)-1].err)
548+ }
549+ markSnapshotDirty(sess)
550+ d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
551+ return failed, true
180 }552 }
181- markSnapshotDirty(sess)
182- d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
183 }553 }
184 554
185 func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {555 func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
@@ -188,27 +558,20 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
188 return false558 return false
189 }559 }
190 snap := rc.resizeSnapshot()560 snap := rc.resizeSnapshot()
191- if !rc.resizeCurrentForLease(epoch, ac, lease, false) {561+ current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
562+ if !current() {
192 return false563 return false
193 }564 }
194 d.exitCopyMode(ac)565 d.exitCopyMode(ac)
195- plan := d.prepareResize(sess, snap.size)566+ failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
196- if !rc.resizeCurrentForLease(epoch, ac, lease, false) {567+ return rc.resizeCurrentForLease(epoch, ac, lease, true)
197- return false568+ })
198- }569+ if !ok {
199- if !d.applyResize(&plan, func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }) {
200 return false570 return false
201 }571 }
202- if !rc.resizeCurrentForLease(epoch, ac, lease, true) {572+ ac.sendMu.Lock()
203- return false573+ ac.size = snap.size
204- }574+ ac.sendMu.Unlock()
205- d.commitResize(sess, ac, plan)
206- failed := make([]resizeMember, 0, len(plan.members))
207- for _, member := range plan.members {
208- if !member.ok {
209- failed = append(failed, member)
210- }
211- }
212 if len(failed) != 0 {575 if len(failed) != 0 {
213 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })576 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
214 }577 }
@@ -225,36 +588,121 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
225 return true588 return true
226 }589 }
227 590
228-// retryResizeMembers retries only members which failed the committed epoch.591+// retryResizeMembers retries failed committed members through a freshly
229-// It never republishes geometry: an intervening resize owns that publication.592+// prepared tab transaction. The captured members identify retry candidates
593+// only: their rectangles must never cross this delayed boundary, because any
594+// later layout mutation may have changed their geometry or removed them.
230 func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {595 func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
231 rc := sess.renderCoordinator()596 rc := sess.renderCoordinator()
232 if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {597 if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
233 return598 return
234 }599 }
235- plan := resizePlan{members: append([]resizeMember(nil), members...)}600+
236- for i := range plan.members {601+ // Retain tab order for deterministic external PTY ordering while collapsing
237- plan.members[i].retry = true602+ // several failed members from the same tab into one canonical transaction.
238- }603+ tabs := make([]*tab, 0, len(members))
239- if !d.applyResize(&plan, func() bool { return rc.retryCurrentForLease(epoch, ac, lease) }) || !rc.retryCurrentForLease(epoch, ac, lease) {604+ seen := make(map[*tab]struct{}, len(members))
240- return605+ for _, member := range members {
606+ if member.tab == nil || member.isFloating {
607+ continue
608+ }
609+ if _, ok := seen[member.tab]; ok {
610+ continue
611+ }
612+ seen[member.tab] = struct{}{}
613+ tabs = append(tabs, member.tab)
241 }614 }
242- failed := make([]resizeMember, 0, len(plan.members))615+
616+ failed := make([]resizeMember, 0, len(members))
243 succeeded := false617 succeeded := false
244- for _, member := range plan.members {618+ for _, tb := range tabs {
245- if !member.ok {619+ if !rc.retryCurrentForLease(epoch, ac, lease) {
246- failed = append(failed, member)620+ return
621+ }
622+ // A later accepted layout transaction clears resizeRetry. In that case
623+ // this delayed callback has no remaining work and, importantly, must not
624+ // replay the obsolete rectangle it captured before the mutation.
625+ retryPending := false
626+ tb.mu.Lock()
627+ for _, member := range members {
628+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
629+ continue
630+ }
631+ member.pane.mu.Lock()
632+ retryPending = retryPending || member.pane.resizeRetry
633+ member.pane.mu.Unlock()
634+ }
635+ tb.mu.Unlock()
636+ if !retryPending {
247 continue637 continue
248 }638 }
249- member.pane.mu.Lock()639+
250- // The rect is the already committed layout target. A successful apply640+ // applyTabLayoutTransaction captures the current generation, pane
251- // already resized before replaying buffered bytes; PTY-less members still641+ // pointers, and solved rectangles, and validates them again before any
252- // need their VT resized here. Force a reset below in either case.642+ // VT/rectangle publication. It also preserves the resize gate and
253- if !member.screenResized {643+ // degradation notice behavior for another failed external attempt.
254- member.pane.screen.Resize(member.rect.Width, member.rect.Height)644+ freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
645+ return rc.retryCurrentForLease(epoch, ac, lease)
646+ })
647+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
648+ return
649+ }
650+ failed = append(failed, freshFailed...)
651+ // A retry success is a formerly failed, still-current target whose
652+ // canonical apply cleared its retry bit. A collapsed or removed target
653+ // is neither a retry completion nor a reason to publish a reset.
654+ tb.mu.Lock()
655+ for _, member := range members {
656+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
657+ continue
658+ }
659+ member.pane.mu.Lock()
660+ succeeded = succeeded || !member.pane.resizeRetry
661+ member.pane.mu.Unlock()
662+ }
663+ tb.mu.Unlock()
664+ }
665+ for _, member := range members {
666+ if !member.isFloating || member.tab == nil || member.pane == nil {
667+ continue
668+ }
669+ if !rc.retryCurrentForLease(epoch, ac, lease) {
670+ return
671+ }
672+ // Floating panes are outside tb.panes. Validate the exact accepted slot
673+ // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
674+ // the same pointer/generation check around external PTY.Resize.
675+ member.tab.mu.Lock()
676+ currentSlot := member.tab.floating.state == floatingVisible &&
677+ member.tab.floating.generation == member.floatingGeneration &&
678+ member.tab.floating.pane == member.pane
679+ retryPending := false
680+ if currentSlot {
681+ member.pane.mu.Lock()
682+ retryPending = member.pane.resizeRetry
683+ member.pane.mu.Unlock()
684+ }
685+ member.tab.mu.Unlock()
686+ if !currentSlot || !retryPending {
687+ continue
688+ }
689+ freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
690+ return rc.retryCurrentForLease(epoch, ac, lease)
691+ }, &member)
692+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
693+ return
694+ }
695+ failed = append(failed, freshFailed...)
696+ member.tab.mu.Lock()
697+ stillCurrent := member.tab.floating.state == floatingVisible &&
698+ member.tab.floating.generation == member.floatingGeneration &&
699+ member.tab.floating.pane == member.pane
700+ member.tab.mu.Unlock()
701+ if stillCurrent && len(freshFailed) == 0 {
702+ member.pane.mu.Lock()
703+ succeeded = succeeded || !member.pane.resizeRetry
704+ member.pane.mu.Unlock()
255 }705 }
256- member.pane.mu.Unlock()
257- succeeded = true
258 }706 }
259 if len(failed) != 0 {707 if len(failed) != 0 {
260 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })708 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
@@ -289,10 +737,8 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
289 if ac == nil {737 if ac == nil {
290 // Headless geometry has no coordinator/transport to coalesce, but keeps738 // Headless geometry has no coordinator/transport to coalesce, but keeps
291 // the same prepare/apply/commit ordering.739 // the same prepare/apply/commit ordering.
292- plan := d.prepareResize(sess, size)740+ _, ok := d.applySessionLayout(sess, size, nil, nil)
293- d.applyResize(&plan)741+ return ok
294- d.commitResize(sess, nil, plan)
295- return true
296 }742 }
297 rc := sess.renderCoordinator()743 rc := sess.renderCoordinator()
298 if rc == nil || !rc.leaseCurrent(lease, true) {744 if rc == nil || !rc.leaseCurrent(lease, true) {
M internal/usecase/daemon/transactional_resize_test.go +185 -90
@@ -181,6 +181,25 @@ func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
181 // invoke PTY.Resize after all session/tab/pane locks are released. This is the181 // invoke PTY.Resize after all session/tab/pane locks are released. This is the
182 // deadlock boundary with PTY readers and scripts which synchronously re-enter182 // deadlock boundary with PTY readers and scripts which synchronously re-enter
183 // daemon state.183 // daemon state.
184+func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
185+ pty := &transactionalResizePTY{}
186+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
187+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
188+ d.beforeSessionResizePublication = sess.cancel
189+
190+ _, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
191+
192+ require.False(t, ok)
193+ require.Len(t, pty.requested(), 1, "cancellation must stop before a stale retry applies another PTY resize")
194+ tb.mu.Lock()
195+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "canceled session published a tab size")
196+ tb.mu.Unlock()
197+ p.mu.Lock()
198+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle")
199+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "canceled session published a VT size")
200+ p.mu.Unlock()
201+}
202+
184 func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {203 func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
185 pty := &transactionalResizePTY{}204 pty := &transactionalResizePTY{}
186 d, sess, _, _ := newManualSessionWithPTYs(t, pty)205 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
@@ -204,105 +223,47 @@ func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
204 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")223 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
205 }224 }
206 225
207-// P3 degradation: a failing PTY resize is surfaced as exactly one warn notice226+// S3 acceptance: a failed member never publishes speculative parser/screen or
208-// per applyResize call, collected inside the resizeMu loop and reported once227+// rectangle state. Successful members and the client layout commit together;
209-// after it returns to lock-free code. The count is the meaningful assertion:228+// composition clips/pads the retained failed screen against that new layout.
210-// notify acquires sess.mu/d.mu and must never run while any resizeMu is held.229+func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
211-func TestTransactionalResizeApplyReportsFailureOncePerCall(t *testing.T) {230+ for _, tc := range []struct {
212- countResizeFailed := func(d *Daemon) (int, domain.Notification) {231+ name string
213- var last domain.Notification232+ failureCount int
214- count := 0233+ wantOK bool
215- for _, h := range d.notices.history() {234+ wantFailed int
216- if h.Code == domain.NoticeResizeFailed {235+ wantFailureNotice int
217- count++
218- last = h
219- }
220- }
221- return count, last
222- }
223-
224- t.Run("one failed member yields one notice after return", func(t *testing.T) {
225- failing := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
226- d, sess, _, _ := newManualSessionWithPTYs(t, failing)
227- tb := sess.activeTab()
228- p := tb.focusedPane()
229- plan := resizePlan{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: domain.Rect{Width: 100, Height: 20}, retry: true}}}
230-
231- require.Empty(t, d.notices.history(), "no notice is recorded before applyResize")
232- require.True(t, d.applyResize(&plan))
233-
234- count, n := countResizeFailed(d)
235- require.Equal(t, 1, count, "exactly one NoticeResizeFailed per applyResize call")
236- require.Equal(t, domain.NoticeWarn, n.Severity)
237- require.Equal(t, "pane resize failed; retrying in background", n.Message)
238- require.False(t, plan.members[0].ok, "failed member is not marked ok")
239- })
240-
241- for _, tt := range []struct {
242- name string
243- firstErr error
244- secondErr error
245- current func() bool
246- wantResult bool
247- wantCount int
248 }{236 }{
249- {237+ {name: "zero failures", wantOK: true},
250- name: "normal completion reports one combined failure",238+ {name: "one failure", failureCount: 1, wantOK: true, wantFailed: 1, wantFailureNotice: 1},
251- firstErr: errors.New("first fails"),239+ {name: "two failures", failureCount: 2, wantOK: true, wantFailed: 2, wantFailureNotice: 1},
252- secondErr: errors.New("second fails"),
253- wantResult: true,
254- wantCount: 1,
255- },
256- {
257- name: "stale current after failure still reports once",
258- firstErr: errors.New("first fails"),
259- secondErr: errors.New("must not be resized"),
260- current: func() func() bool {
261- checks := 0
262- return func() bool {
263- checks++
264- return checks == 1
265- }
266- }(),
267- wantResult: false,
268- wantCount: 1,
269- },
270- {
271- name: "normal completion without failure does not report",
272- wantResult: true,
273- wantCount: 0,
274- },
275 } {240 } {
276- t.Run(tt.name, func(t *testing.T) {241+ t.Run(tc.name, func(t *testing.T) {
277- first := &transactionalResizePTY{errs: []error{tt.firstErr}}242+ first := &transactionalResizePTY{}
278- second := &transactionalResizePTY{errs: []error{tt.secondErr}}243+ second := &transactionalResizePTY{}
279- d, sess, _, _ := newManualSessionWithPTYs(t, first, second)244+ if tc.failureCount >= 1 {
280- tabs := sess.tabs245+ first.errs = []error{errors.New("first failure")}
281- plan := resizePlan{members: []resizeMember{
282- {session: sess, tab: tabs[0], pane: tabs[0].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true},
283- {session: sess, tab: tabs[1], pane: tabs[1].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true},
284- }}
285-
286- var got bool
287- if tt.current == nil {
288- got = d.applyResize(&plan)
289- } else {
290- got = d.applyResize(&plan, tt.current)
291 }246 }
292- require.Equal(t, tt.wantResult, got)247+ if tc.failureCount >= 2 {
248+ second.errs = []error{errors.New("second failure")}
249+ }
250+ d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
251+
252+ failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
293 253
294- count, _ := countResizeFailed(d)254+ require.Equal(t, tc.wantOK, ok)
295- require.Equal(t, tt.wantCount, count, "one notice per applyResize call, including stale returns")255+ require.Len(t, failed, tc.wantFailed)
296- if !tt.wantResult {256+ count := 0
297- require.Equal(t, []domain.Size(nil), second.requested(), "stale current check must stop before the next member")257+ for _, notification := range d.notices.history() {
258+ if notification.Code == domain.NoticeResizeFailed {
259+ count++
260+ }
298 }261 }
262+ require.Equal(t, tc.wantFailureNotice, count)
299 })263 })
300 }264 }
301 }265 }
302 266
303-// S3 acceptance: a failed member never publishes speculative parser/screen or
304-// rectangle state. Successful members and the client layout commit together;
305-// composition clips/pads the retained failed screen against that new layout.
306 func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {267 func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
307 ok := &transactionalResizePTY{}268 ok := &transactionalResizePTY{}
308 failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}269 failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
@@ -423,6 +384,140 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
423 // stopped. Only the newest prepared epoch may apply, commit, and emit one full384 // stopped. Only the newest prepared epoch may apply, commit, and emit one full
424 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its385 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
425 // channels make callback order explicit and do not use wall-clock waits.386 // channels make callback order explicit and do not use wall-clock waits.
387+func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
388+ first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
389+ d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
390+ observer := &daemonRuntimeObserver{}
391+ d.runtimeObserver = observer
392+ rc := d.attachCoordinator(sess, nil, ac, true)
393+ lease := rc.attachmentLease(ac)
394+ var newer uint64
395+ d.beforeSessionResizePublication = func() {
396+ // Final external PTY validation is complete, but epoch admission and
397+ // every session-visible publication must still be pending.
398+ for _, tb := range sess.tabs {
399+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
400+ p := tb.focusedPane()
401+ p.mu.Lock()
402+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect)
403+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
404+ p.mu.Unlock()
405+ }
406+ require.False(t, sess.snapDirty.Load())
407+ for _, mark := range observer.marks {
408+ require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind)
409+ }
410+ newer = rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
411+ require.NotZero(t, newer)
412+ }
413+
414+ require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
415+ d.beforeSessionResizePublication = nil
416+ require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot")
417+ for _, tb := range sess.tabs {
418+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size")
419+ p := tb.focusedPane()
420+ require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle")
421+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size")
422+ }
423+
424+ require.True(t, d.runResizeTransaction(sess, ac, lease, newer))
425+ for _, tb := range sess.tabs {
426+ require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
427+ p := tb.focusedPane()
428+ require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect)
429+ require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
430+ }
431+ committed := 0
432+ for _, mark := range observer.marks {
433+ if mark.Kind == ports.RuntimeResizeCommitted {
434+ committed++
435+ }
436+ }
437+ require.Equal(t, 1, committed, "only the current epoch emits commit telemetry")
438+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, first.requested())
439+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
440+}
441+
442+func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
443+ pty := &transactionalResizePTY{}
444+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
445+ tb := sess.activeTab()
446+ p := tb.focusedPane()
447+ p.resizeApplying = true
448+ p.resizePending = []byte("x")
449+ plan := preparedTabLayout{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect, screenResized: true}}}
450+ tb.mu.Lock()
451+ delete(tb.panes, p.id)
452+ tb.bumpLayoutGenerationLocked()
453+ tb.mu.Unlock()
454+
455+ d.finishPreparedTabMembers(&plan, false)
456+ p.mu.Lock()
457+ require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path")
458+ p.mu.Unlock()
459+ d.cancelStalePreparedGates(sess, tb, &plan)
460+ p.mu.Lock()
461+ require.False(t, p.resizeApplying)
462+ require.Empty(t, p.resizePending)
463+ p.mu.Unlock()
464+}
465+
466+func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
467+ pty := &transactionalResizePTY{}
468+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
469+ tb := sess.activeTab()
470+ var invalidate sync.Once
471+ pty.onResize = func() {
472+ invalidate.Do(func() {
473+ tb.mu.Lock()
474+ tb.bumpLayoutGenerationLocked()
475+ tb.mu.Unlock()
476+ })
477+ }
478+
479+ require.True(t, d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true))
480+ require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested())
481+ tb.mu.Lock()
482+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size)
483+ tb.mu.Unlock()
484+}
485+
486+func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
487+ popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
488+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
489+ tb := sess.activeTab()
490+ popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
491+ tb.mu.Lock()
492+ tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
493+ tb.mu.Unlock()
494+
495+ rc := d.attachCoordinator(sess, nil, ac, true)
496+ lease := rc.attachmentLease(ac)
497+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
498+ require.NotZero(t, epoch)
499+ require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
500+ first := popupPTY.requested()
501+ require.Len(t, first, 1)
502+
503+ d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
504+ retried := popupPTY.requested()
505+ require.Len(t, retried, 2)
506+ require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry")
507+ popup.mu.Lock()
508+ require.False(t, popup.resizeRetry)
509+ popup.mu.Unlock()
510+
511+ replacementPTY := &transactionalResizePTY{}
512+ replacement := newPane("replacement", replacementPTY, domain.Size{Cols: 80, Rows: 23})
513+ replacement.resizeRetry = true
514+ tb.mu.Lock()
515+ tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}
516+ tb.mu.Unlock()
517+ d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
518+ require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")
519+}
520+
426 func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {521 func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
427 clock := newCoordinatorMockClock(t, 8)522 clock := newCoordinatorMockClock(t, 8)
428 pty := &transactionalResizePTY{}523 pty := &transactionalResizePTY{}
M internal/usecase/keys/keyspec.go +5 -0
@@ -65,6 +65,11 @@ var actionNames = map[Action]string{
65 ActionSwitchTab7: "switch-tab-7",65 ActionSwitchTab7: "switch-tab-7",
66 ActionSwitchTab8: "switch-tab-8",66 ActionSwitchTab8: "switch-tab-8",
67 ActionSwitchTab9: "switch-tab-9",67 ActionSwitchTab9: "switch-tab-9",
68+ ActionGrowPaneWidth: "grow-pane-width",
69+ ActionShrinkPaneWidth: "shrink-pane-width",
70+ ActionGrowPaneHeight: "grow-pane-height",
71+ ActionShrinkPaneHeight: "shrink-pane-height",
72+ ActionEqualizePanes: "equalize-panes",
68 }73 }
69 74
70 var actionsByName = func() map[string]Action {75 var actionsByName = func() map[string]Action {
M internal/usecase/keys/keyspec_test.go +35 -0
@@ -79,6 +79,11 @@ func TestActionNamesAreCanonical(t *testing.T) {
79 {ActionSwitchTab7, "switch-tab-7"},79 {ActionSwitchTab7, "switch-tab-7"},
80 {ActionSwitchTab8, "switch-tab-8"},80 {ActionSwitchTab8, "switch-tab-8"},
81 {ActionSwitchTab9, "switch-tab-9"},81 {ActionSwitchTab9, "switch-tab-9"},
82+ {ActionGrowPaneWidth, "grow-pane-width"},
83+ {ActionShrinkPaneWidth, "shrink-pane-width"},
84+ {ActionGrowPaneHeight, "grow-pane-height"},
85+ {ActionShrinkPaneHeight, "shrink-pane-height"},
86+ {ActionEqualizePanes, "equalize-panes"},
82 }87 }
83 88
84 for _, tc := range cases {89 for _, tc := range cases {
@@ -114,6 +119,36 @@ func TestBuildBindingsAppliesOverrides(t *testing.T) {
114 assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2)119 assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2)
115 }120 }
116 121
122+func TestResizeActionsBindWithoutDefaults(t *testing.T) {
123+ tests := []struct {
124+ name string
125+ action Action
126+ key rune
127+ }{
128+ {"grow-pane-width", ActionGrowPaneWidth, 'w'},
129+ {"shrink-pane-width", ActionShrinkPaneWidth, 's'},
130+ {"grow-pane-height", ActionGrowPaneHeight, 'g'},
131+ {"shrink-pane-height", ActionShrinkPaneHeight, 'r'},
132+ {"equalize-panes", ActionEqualizePanes, 'e'},
133+ }
134+
135+ defaults := DefaultBindings()
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ for _, bound := range defaults.altRunes {
139+ require.NotEqual(t, tt.action, bound)
140+ }
141+ for _, bound := range defaults.altArrows {
142+ require.NotEqual(t, tt.action, bound)
143+ }
144+
145+ bindings, warnings := BuildBindings(map[string]string{tt.name: "alt+" + string(tt.key)})
146+ require.Empty(t, warnings)
147+ assertAltRuneAction(t, bindings, tt.key, tt.action)
148+ })
149+ }
150+}
151+
117 func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) {152 func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) {
118 bindings, warnings := BuildBindings(map[string]string{153 bindings, warnings := BuildBindings(map[string]string{
119 "missing": "alt+m",154 "missing": "alt+m",
M internal/usecase/keys/router.go +5 -0
@@ -38,6 +38,11 @@ const (
38 ActionFocusPaneUp38 ActionFocusPaneUp
39 ActionFocusPaneDown39 ActionFocusPaneDown
40 ActionToggleFloatingPane40 ActionToggleFloatingPane
41+ ActionGrowPaneWidth
42+ ActionShrinkPaneWidth
43+ ActionGrowPaneHeight
44+ ActionShrinkPaneHeight
45+ ActionEqualizePanes
41 )46 )
42 47
43 // Handler receives router outputs. Forward is called only for bytes that should48 // Handler receives router outputs. Forward is called only for bytes that should
M internal/usecase/layout/geometry.go +200 -30
@@ -1,12 +1,25 @@
1 package layout1 package layout
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "math"
5+ "sort"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
4 9
5 // Solve returns deterministic pane placements for root inside area.10 // Solve returns deterministic pane placements for root inside area.
6 func Solve(root *Node, area domain.Rect) ([]Placement, bool) {11 func Solve(root *Node, area domain.Rect) ([]Placement, bool) {
7 if root == nil || area.Width <= 0 || area.Height <= 0 {12 if root == nil || area.Width <= 0 || area.Height <= 0 {
8 return nil, false13 return nil, false
9 }14 }
15+ minWidth, ok := minimumExtent(root, Horizontal)
16+ if !ok || area.Width < minWidth {
17+ return nil, false
18+ }
19+ minHeight, ok := minimumExtent(root, Vertical)
20+ if !ok || area.Height < minHeight {
21+ return nil, false
22+ }
10 var out []Placement23 var out []Placement
11 if !solve(root, area, &out) {24 if !solve(root, area, &out) {
12 return nil, false25 return nil, false
@@ -32,50 +45,207 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool {
32 }45 }
33 46
34 func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {47 func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {
48+ rects, ok := splitChildRects(n, r)
49+ if !ok {
50+ return false
51+ }
52+ for i, child := range n.Children {
53+ if !solve(child, rects[i], out) {
54+ return false
55+ }
56+ }
57+ return true
58+}
59+
60+func splitChildRects(n *Node, r domain.Rect) ([]domain.Rect, bool) {
35 count := len(n.Children)61 count := len(n.Children)
36 if count == 0 {62 if count == 0 {
37- return false63+ return nil, false
38 }64 }
39 if count == 1 {65 if count == 1 {
40- return solve(n.Children[0], r, out)66+ return []domain.Rect{r}, true
41 }67 }
42- if n.Dir == Horizontal {68+
43- usable := r.Width - (count - 1)69+ total := r.Width
44- if usable < count*MinPaneCols || r.Height < MinPaneRows {70+ if n.Dir == Vertical {
45- return false71+ total = r.Height
72+ }
73+ usable := total - (count - 1)
74+ minimums := make([]int, count)
75+ weights := make([]float64, count)
76+ for i, child := range n.Children {
77+ minimum, ok := minimumExtent(child, n.Dir)
78+ if !ok {
79+ return nil, false
80+ }
81+ minimums[i] = minimum
82+ weights[i] = effectiveWeight(child.Weight)
83+ }
84+ extents, ok := distribute(usable, minimums, weights)
85+ if !ok {
86+ return nil, false
87+ }
88+
89+ rects := make([]domain.Rect, count)
90+ x, y := r.X, r.Y
91+ for i, extent := range extents {
92+ if n.Dir == Horizontal {
93+ rects[i] = domain.Rect{X: x, Y: r.Y, Width: extent, Height: r.Height}
94+ x += extent + 1
95+ continue
96+ }
97+ rects[i] = domain.Rect{X: r.X, Y: y, Width: r.Width, Height: extent}
98+ y += extent + 1
99+ }
100+ return rects, true
101+}
102+
103+func effectiveWeight(weight float64) float64 {
104+ if weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
105+ return 1
106+ }
107+ return weight
108+}
109+
110+func minimumExtent(n *Node, axis SplitDir) (int, bool) {
111+ if n == nil {
112+ return 0, false
113+ }
114+ switch n.Kind {
115+ case Leaf:
116+ if axis == Horizontal {
117+ return MinPaneCols, true
118+ }
119+ return MinPaneRows, true
120+ case Stack:
121+ if len(n.Children) == 0 {
122+ return 0, false
123+ }
124+ for _, child := range n.Children {
125+ if child == nil || child.Kind != Leaf {
126+ return 0, false
127+ }
128+ }
129+ if axis == Horizontal {
130+ return MinPaneCols, true
131+ }
132+ return len(n.Children) + 1, true
133+ case Split:
134+ if len(n.Children) == 0 {
135+ return 0, false
46 }136 }
47- base, rem := usable/count, usable%count137+ if n.Dir == axis {
48- x := r.X138+ total := len(n.Children) - 1
49- for i, child := range n.Children {139+ for _, child := range n.Children {
50- w := base140+ minimum, ok := minimumExtent(child, axis)
51- if i < rem {141+ if !ok {
52- w++142+ return 0, false
143+ }
144+ total += minimum
53 }145 }
54- if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) {146+ return total, true
55- return false147+ }
148+ largest := 0
149+ for _, child := range n.Children {
150+ minimum, ok := minimumExtent(child, axis)
151+ if !ok {
152+ return 0, false
56 }153 }
57- x += w + 1154+ largest = max(largest, minimum)
58 }155 }
59- return true156+ return largest, true
157+ default:
158+ return 0, false
60 }159 }
160+}
61 161
62- usable := r.Height - (count - 1)162+// distribute allocates total integer cells proportionally while respecting each
63- if usable < count*MinPaneRows || r.Width < MinPaneCols {163+// child's minimum. It uses stable largest-remainder rounding after all
64- return false164+// constrained children have been pinned.
165+func distribute(total int, minimums []int, weights []float64) ([]int, bool) {
166+ if total < 0 || len(minimums) == 0 || len(minimums) != len(weights) {
167+ return nil, false
65 }168 }
66- base, rem := usable/count, usable%count169+ minimumTotal := 0
67- y := r.Y170+ for _, minimum := range minimums {
68- for i, child := range n.Children {171+ if minimum < 0 {
69- h := base172+ return nil, false
70- if i < rem {
71- h++
72 }173 }
73- if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) {174+ minimumTotal += minimum
74- return false175+ }
176+ if minimumTotal > total {
177+ return nil, false
178+ }
179+
180+ allocations := make([]int, len(minimums))
181+ active := make([]bool, len(minimums))
182+ for i := range active {
183+ active[i] = true
184+ }
185+ remaining := total
186+
187+ for {
188+ largest, sum := activeWeightScale(weights, active)
189+ var pinned []int
190+ for i := range active {
191+ if !active[i] {
192+ continue
193+ }
194+ quota := float64(remaining) * (effectiveWeight(weights[i]) / largest) / sum
195+ if quota < float64(minimums[i]) {
196+ pinned = append(pinned, i)
197+ }
198+ }
199+ if len(pinned) == 0 {
200+ break
201+ }
202+ for _, i := range pinned {
203+ allocations[i] = minimums[i]
204+ remaining -= minimums[i]
205+ active[i] = false
75 }206 }
76- y += h + 1
77 }207 }
78- return true208+
209+ largest, sum := activeWeightScale(weights, active)
210+ type remainder struct {
211+ index int
212+ fraction float64
213+ }
214+ remainders := make([]remainder, 0, len(weights))
215+ allocated := total - remaining
216+ for i := range active {
217+ if !active[i] {
218+ continue
219+ }
220+ quota := float64(remaining) * (effectiveWeight(weights[i]) / largest) / sum
221+ whole := int(math.Floor(quota))
222+ allocations[i] = whole
223+ allocated += whole
224+ remainders = append(remainders, remainder{index: i, fraction: quota - float64(whole)})
225+ }
226+ sort.SliceStable(remainders, func(i, j int) bool {
227+ return remainders[i].fraction > remainders[j].fraction
228+ })
229+ for cells, i := total-allocated, 0; cells > 0; cells, i = cells-1, i+1 {
230+ allocations[remainders[i%len(remainders)].index]++
231+ }
232+ return allocations, true
233+}
234+
235+func activeWeightScale(weights []float64, active []bool) (float64, float64) {
236+ largest := 0.0
237+ for i, weight := range weights {
238+ if active[i] {
239+ largest = math.Max(largest, effectiveWeight(weight))
240+ }
241+ }
242+ sum := 0.0
243+ for i, weight := range weights {
244+ if active[i] {
245+ sum += effectiveWeight(weight) / largest
246+ }
247+ }
248+ return largest, sum
79 }249 }
80 250
81 func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {251 func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {
M internal/usecase/layout/ops.go +255 -19
@@ -2,6 +2,7 @@ package layout
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "math"
5 "sort"6 "sort"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -11,14 +12,198 @@ var (
11 ErrNotFound = errors.New("pane not found")12 ErrNotFound = errors.New("pane not found")
12 ErrTooSmall = errors.New("layout too small")13 ErrTooSmall = errors.New("layout too small")
13 ErrNoPane = errors.New("no pane in direction")14 ErrNoPane = errors.New("no pane in direction")
15+ ErrNotInSplit = errors.New("pane is not in a split")
14 ErrNotToggleable = errors.New("layout node is not toggleable")16 ErrNotToggleable = errors.New("layout node is not toggleable")
15 )17 )
16 18
19+// ResizeFocus changes the focused pane subtree's share in the nearest split on
20+// axis. It commits only after the candidate layout solves successfully.
21+func (t *Tree) ResizeFocus(axis Axis, delta int, area domain.Rect) error {
22+ candidate := t.clone()
23+ if candidate == nil || candidate.Root == nil {
24+ return ErrNotInSplit
25+ }
26+ dir, ok := splitDirForAxis(axis)
27+ if !ok || !hasMatchingSplit(candidate.Root, candidate.Focus, dir) {
28+ return ErrNotInSplit
29+ }
30+ if _, solved := Solve(candidate.Root, area); !solved {
31+ return ErrTooSmall
32+ }
33+
34+ splitNode, splitArea, targetIndex, found := nearestMatchingSplit(candidate.Root, candidate.Focus, dir, area)
35+ if !found {
36+ return ErrTooSmall
37+ }
38+ rects, solved := splitChildRects(splitNode, splitArea)
39+ if !solved {
40+ return ErrTooSmall
41+ }
42+ extents := make([]int, len(rects))
43+ minimums := make([]int, len(rects))
44+ for i, rect := range rects {
45+ extents[i] = rect.Width
46+ if dir == Vertical {
47+ extents[i] = rect.Height
48+ }
49+ minimum, valid := minimumExtent(splitNode.Children[i], dir)
50+ if !valid {
51+ return ErrTooSmall
52+ }
53+ minimums[i] = minimum
54+ splitNode.Children[i].Weight = float64(extents[i])
55+ }
56+
57+ requested := delta
58+ if requested < 0 {
59+ if requested == math.MinInt {
60+ requested = math.MaxInt
61+ } else {
62+ requested = -requested
63+ }
64+ }
65+ if requested == 0 {
66+ return ErrTooSmall
67+ }
68+
69+ neighbor := -1
70+ transfer := 0
71+ if delta > 0 {
72+ for _, index := range preferredNeighbors(targetIndex, len(extents)) {
73+ available := extents[index] - minimums[index]
74+ if available <= 0 {
75+ continue
76+ }
77+ neighbor = index
78+ transfer = min(requested, available)
79+ break
80+ }
81+ } else {
82+ neighbors := preferredNeighbors(targetIndex, len(extents))
83+ available := extents[targetIndex] - minimums[targetIndex]
84+ if len(neighbors) > 0 && available > 0 {
85+ neighbor = neighbors[0]
86+ transfer = min(requested, available)
87+ }
88+ }
89+ if neighbor < 0 || transfer == 0 {
90+ return ErrTooSmall
91+ }
92+
93+ if delta > 0 {
94+ extents[targetIndex] += transfer
95+ extents[neighbor] -= transfer
96+ } else {
97+ extents[targetIndex] -= transfer
98+ extents[neighbor] += transfer
99+ }
100+ splitNode.Children[targetIndex].Weight = float64(extents[targetIndex])
101+ splitNode.Children[neighbor].Weight = float64(extents[neighbor])
102+ if _, solved := Solve(candidate.Root, area); !solved {
103+ return ErrTooSmall
104+ }
105+ *t = *candidate
106+ return nil
107+}
108+
109+// Equalize resets every split's direct child shares to their defaults. The
110+// candidate must remain solvable before it is committed.
111+func (t *Tree) Equalize(area domain.Rect) error {
112+ candidate := t.clone()
113+ if candidate == nil || candidate.Root == nil {
114+ return ErrTooSmall
115+ }
116+ clearSplitChildWeights(candidate.Root)
117+ if _, ok := Solve(candidate.Root, area); !ok {
118+ return ErrTooSmall
119+ }
120+ *t = *candidate
121+ return nil
122+}
123+
124+// CanResize reports whether the focused pane is contained by any split.
125+func (t *Tree) CanResize() bool {
126+ if t == nil || t.Root == nil {
127+ return false
128+ }
129+ return hasMatchingSplit(t.Root, t.Focus, Horizontal) || hasMatchingSplit(t.Root, t.Focus, Vertical)
130+}
131+
132+func splitDirForAxis(axis Axis) (SplitDir, bool) {
133+ switch axis {
134+ case Width:
135+ return Horizontal, true
136+ case Height:
137+ return Vertical, true
138+ default:
139+ return Horizontal, false
140+ }
141+}
142+
143+func hasMatchingSplit(n *Node, focus PaneID, dir SplitDir) bool {
144+ if n == nil || !containsLeaf(n, focus) {
145+ return false
146+ }
147+ for _, child := range n.Children {
148+ if containsLeaf(child, focus) && hasMatchingSplit(child, focus, dir) {
149+ return true
150+ }
151+ }
152+ return n.Kind == Split && n.Dir == dir
153+}
154+
155+func nearestMatchingSplit(n *Node, focus PaneID, dir SplitDir, area domain.Rect) (*Node, domain.Rect, int, bool) {
156+ if n == nil || !containsLeaf(n, focus) {
157+ return nil, domain.Rect{}, 0, false
158+ }
159+ childAreas := nodeChildAreas(n, area)
160+ for i, child := range n.Children {
161+ if !containsLeaf(child, focus) {
162+ continue
163+ }
164+ childArea := area
165+ if i < len(childAreas) {
166+ childArea = childAreas[i]
167+ }
168+ if match, matchArea, index, ok := nearestMatchingSplit(child, focus, dir, childArea); ok {
169+ return match, matchArea, index, true
170+ }
171+ if n.Kind == Split && n.Dir == dir {
172+ return n, area, i, true
173+ }
174+ return nil, domain.Rect{}, 0, false
175+ }
176+ return nil, domain.Rect{}, 0, false
177+}
178+
179+func preferredNeighbors(target, count int) []int {
180+ neighbors := make([]int, 0, 2)
181+ if target+1 < count {
182+ neighbors = append(neighbors, target+1)
183+ }
184+ if target > 0 {
185+ neighbors = append(neighbors, target-1)
186+ }
187+ return neighbors
188+}
189+
190+func clearSplitChildWeights(n *Node) {
191+ if n == nil {
192+ return
193+ }
194+ if n.Kind == Split {
195+ clearChildWeights(n)
196+ }
197+ for _, child := range n.Children {
198+ clearSplitChildWeights(child)
199+ }
200+}
201+
17 // Split adds newID next to target. The after flag controls whether the new pane202 // Split adds newID next to target. The after flag controls whether the new pane
18 // is placed after target on the chosen axis.203 // is placed after target on the chosen axis.
19 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {204 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {
20 candidate := t.clone()205 candidate := t.clone()
21- if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID) {206+ if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID, area) {
22 return ErrNotFound207 return ErrNotFound
23 }208 }
24 candidate.Focus = newID209 candidate.Focus = newID
@@ -29,14 +214,16 @@ func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, are
29 return nil214 return nil
30 }215 }
31 216
32-func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool {217+func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID, area domain.Rect) bool {
33 if n == nil {218 if n == nil {
34 return false219 return false
35 }220 }
221+ childAreas := nodeChildAreas(n, area)
36 for i, child := range n.Children {222 for i, child := range n.Children {
37 if child.Kind == Leaf && child.Leaf == target {223 if child.Kind == Leaf && child.Leaf == target {
38 newLeaf := NewLeaf(newID)224 newLeaf := NewLeaf(newID)
39 if n.Kind == Split && n.Dir == axis {225 if n.Kind == Split && n.Dir == axis {
226+ normalizeChildWeightsFromArea(n, area)
40 at := i227 at := i
41 if after {228 if after {
42 at++229 at++
@@ -47,36 +234,82 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID
47 return true234 return true
48 }235 }
49 if n.Kind == Stack {236 if n.Kind == Stack {
237+ weight := n.Weight
50 stack := n.clone()238 stack := n.clone()
239+ stack.Weight = 0
240+ clearChildWeights(stack)
51 children := []*Node{stack, newLeaf}241 children := []*Node{stack, newLeaf}
52 if !after {242 if !after {
53 children = []*Node{newLeaf, stack}243 children = []*Node{newLeaf, stack}
54 }244 }
55- *n = Node{Kind: Split, Dir: axis, Children: children}245+ *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
56 return true246 return true
57 }247 }
248+ weight := child.Weight
249+ child.Weight = 0
58 children := []*Node{child, newLeaf}250 children := []*Node{child, newLeaf}
59 if !after {251 if !after {
60 children = []*Node{newLeaf, child}252 children = []*Node{newLeaf, child}
61 }253 }
62- n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children}254+ n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
63 return true255 return true
64 }256 }
65- if insertSplit(child, target, axis, after, newID) {257+ childArea := area
258+ if i < len(childAreas) {
259+ childArea = childAreas[i]
260+ }
261+ if insertSplit(child, target, axis, after, newID, childArea) {
66 return true262 return true
67 }263 }
68 }264 }
69 if n.Kind == Leaf && n.Leaf == target {265 if n.Kind == Leaf && n.Leaf == target {
70- children := []*Node{n.clone(), NewLeaf(newID)}266+ weight := n.Weight
267+ existing := n.clone()
268+ existing.Weight = 0
269+ children := []*Node{existing, NewLeaf(newID)}
71 if !after {270 if !after {
72- children = []*Node{NewLeaf(newID), n.clone()}271+ children = []*Node{NewLeaf(newID), existing}
73 }272 }
74- *n = Node{Kind: Split, Dir: axis, Children: children}273+ *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight}
75 return true274 return true
76 }275 }
77 return false276 return false
78 }277 }
79 278
279+func nodeChildAreas(n *Node, area domain.Rect) []domain.Rect {
280+ if n.Kind == Split {
281+ rects, ok := splitChildRects(n, area)
282+ if ok {
283+ return rects
284+ }
285+ }
286+ areas := make([]domain.Rect, len(n.Children))
287+ for i := range areas {
288+ areas[i] = area
289+ }
290+ return areas
291+}
292+
293+func normalizeChildWeightsFromArea(n *Node, area domain.Rect) {
294+ rects, ok := splitChildRects(n, area)
295+ if !ok || len(rects) == 0 {
296+ return
297+ }
298+ for i, rect := range rects {
299+ extent := rect.Width
300+ if n.Dir == Vertical {
301+ extent = rect.Height
302+ }
303+ n.Children[i].Weight = float64(extent)
304+ }
305+}
306+
307+func clearChildWeights(n *Node) {
308+ for _, child := range n.Children {
309+ child.Weight = 0
310+ }
311+}
312+
80 // StackNew puts newID in target's stack, creating one if target is not stacked.313 // StackNew puts newID in target's stack, creating one if target is not stacked.
81 func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error {314 func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error {
82 candidate := t.clone()315 candidate := t.clone()
@@ -100,13 +333,16 @@ func stackNew(n *Node, target, newID PaneID) bool {
100 if child.Kind == Leaf && child.Leaf == target {333 if child.Kind == Leaf && child.Leaf == target {
101 n.Children = append(n.Children, NewLeaf(newID))334 n.Children = append(n.Children, NewLeaf(newID))
102 n.Expanded = newID335 n.Expanded = newID
336+ clearChildWeights(n)
103 return true337 return true
104 }338 }
105 }339 }
106 }340 }
107 for i, child := range n.Children {341 for i, child := range n.Children {
108 if child.Kind == Leaf && child.Leaf == target {342 if child.Kind == Leaf && child.Leaf == target {
109- n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID}343+ weight := child.Weight
344+ child.Weight = 0
345+ n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID, Weight: weight}
110 return true346 return true
111 }347 }
112 if stackNew(child, target, newID) {348 if stackNew(child, target, newID) {
@@ -114,7 +350,8 @@ func stackNew(n *Node, target, newID PaneID) bool {
114 }350 }
115 }351 }
116 if n.Kind == Leaf && n.Leaf == target {352 if n.Kind == Leaf && n.Leaf == target {
117- *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID}353+ weight := n.Weight
354+ *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID, Weight: weight}
118 return true355 return true
119 }356 }
120 return false357 return false
@@ -169,11 +406,13 @@ func toggleStack(n *Node, target PaneID) toggleResult {
169 n.Kind = Split406 n.Kind = Split
170 n.Dir = Vertical407 n.Dir = Vertical
171 n.Expanded = ""408 n.Expanded = ""
409+ clearChildWeights(n)
172 return toggleToggled410 return toggleToggled
173 }411 }
174 if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) {412 if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) {
175 n.Kind = Stack413 n.Kind = Stack
176 n.Expanded = target414 n.Expanded = target
415+ clearChildWeights(n)
177 return toggleToggled416 return toggleToggled
178 }417 }
179 return toggleNotToggleable418 return toggleNotToggleable
@@ -252,16 +491,13 @@ func closeNode(n *Node, target PaneID) (*Node, bool) {
252 if !removed {491 if !removed {
253 return n, false492 return n, false
254 }493 }
255- if n.Kind == Stack {494+ if n.Kind == Stack && n.Expanded == target && len(n.Children) > 0 {
256- if n.Expanded == target && len(n.Children) > 0 {495+ n.Expanded = firstLeaf(n.Children[0])
257- n.Expanded = firstLeaf(n.Children[0])
258- }
259- if len(n.Children) == 1 {
260- return n.Children[0], true
261- }
262 }496 }
263- if n.Kind == Split && len(n.Children) == 1 {497+ if (n.Kind == Stack || n.Kind == Split) && len(n.Children) == 1 {
264- return n.Children[0], true498+ promoted := n.Children[0]
499+ promoted.Weight = n.Weight
500+ return promoted, true
265 }501 }
266 return n, true502 return n, true
267 }503 }
A internal/usecase/layout/resize_test.go +321 -0
@@ -0,0 +1,321 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestResizeFocusTransfersSolvedExtent(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ root *Node
16+ focus PaneID
17+ axis Axis
18+ delta int
19+ area domain.Rect
20+ want map[PaneID]domain.Rect
21+ }{
22+ {
23+ name: "grow width takes from right neighbor",
24+ root: split(Horizontal, NewLeaf("a"), NewLeaf("b")),
25+ focus: "a",
26+ axis: Width,
27+ delta: 5,
28+ area: domain.Rect{Width: 51, Height: 2},
29+ want: map[PaneID]domain.Rect{
30+ "a": {Width: 30, Height: 2},
31+ "b": {X: 31, Width: 20, Height: 2},
32+ },
33+ },
34+ {
35+ name: "shrink width gives to right neighbor",
36+ root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)),
37+ focus: "a",
38+ axis: Width,
39+ delta: -5,
40+ area: domain.Rect{Width: 51, Height: 2},
41+ want: map[PaneID]domain.Rect{
42+ "a": {Width: 25, Height: 2},
43+ "b": {X: 26, Width: 25, Height: 2},
44+ },
45+ },
46+ {
47+ name: "grow height takes from bottom neighbor",
48+ root: split(Vertical,
49+ weightedLeaf("a", 4), weightedLeaf("b", 3),
50+ ),
51+ focus: "a",
52+ axis: Height,
53+ delta: 1,
54+ area: domain.Rect{Width: 20, Height: 8},
55+ want: map[PaneID]domain.Rect{
56+ "a": {Width: 20, Height: 5},
57+ "b": {Y: 6, Width: 20, Height: 2},
58+ },
59+ },
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ tr := &Tree{Root: tt.root, Focus: tt.focus}
65+ require.NoError(t, tr.ResizeFocus(tt.axis, tt.delta, tt.area))
66+ placements, ok := Solve(tr.Root, tt.area)
67+ require.True(t, ok)
68+ require.Equal(t, tt.want, contents(placements))
69+ })
70+ }
71+}
72+
73+func TestResizeFocusSelectsNearestTargetAndAdjacentSibling(t *testing.T) {
74+ t.Parallel()
75+
76+ t.Run("nearest matching ancestor", func(t *testing.T) {
77+ inner := split(Horizontal, NewLeaf("a"), NewLeaf("b"))
78+ tr := &Tree{Root: split(Horizontal, weightedNode(inner, 2), weightedLeaf("c", 1)), Focus: "a"}
79+ area := domain.Rect{Width: 122, Height: 2}
80+ before, ok := Solve(tr.Root, area)
81+ require.True(t, ok)
82+
83+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
84+ after, ok := Solve(tr.Root, area)
85+ require.True(t, ok)
86+ require.Equal(t, contents(before)["c"], contents(after)["c"])
87+ require.Equal(t, contents(before)["a"].Width+5, contents(after)["a"].Width)
88+ require.Equal(t, contents(before)["b"].Width-5, contents(after)["b"].Width)
89+ })
90+
91+ t.Run("direct child subtree is resized as one target", func(t *testing.T) {
92+ subtree := split(Vertical, NewLeaf("a"), NewLeaf("d"))
93+ tr := &Tree{Root: split(Horizontal, subtree, NewLeaf("b")), Focus: "a"}
94+ area := domain.Rect{Width: 51, Height: 5}
95+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
96+ placements, ok := Solve(tr.Root, area)
97+ require.True(t, ok)
98+ require.Equal(t, 30, contents(placements)["a"].Width)
99+ require.Equal(t, 30, contents(placements)["d"].Width)
100+ require.Equal(t, 20, contents(placements)["b"].Width)
101+ })
102+
103+ t.Run("stack is resized as one target", func(t *testing.T) {
104+ stackNode := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("d")}, Expanded: "a"}
105+ tr := &Tree{Root: split(Horizontal, stackNode, NewLeaf("b")), Focus: "d"}
106+ area := domain.Rect{Width: 51, Height: 3}
107+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
108+ placements, ok := Solve(tr.Root, area)
109+ require.True(t, ok)
110+ require.Equal(t, 30, contents(placements)["a"].Width)
111+ var collapsedTitleWidth int
112+ for _, placement := range placements {
113+ if placement.ID == "d" {
114+ collapsedTitleWidth = placement.TitleBar.Width
115+ }
116+ }
117+ require.Equal(t, 30, collapsedTitleWidth)
118+ require.Equal(t, 20, contents(placements)["b"].Width)
119+ })
120+
121+ t.Run("right donor at minimum falls back to left", func(t *testing.T) {
122+ tr := &Tree{Root: split(Horizontal,
123+ weightedLeaf("a", 40), weightedLeaf("b", 20), weightedLeaf("c", 20),
124+ ), Focus: "b"}
125+ area := domain.Rect{Width: 82, Height: 2}
126+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
127+ placements, ok := Solve(tr.Root, area)
128+ require.True(t, ok)
129+ require.Equal(t, map[PaneID]int{"a": 35, "b": 25, "c": 20}, placementWidths(placements))
130+ })
131+
132+ t.Run("last target falls back to left neighbor", func(t *testing.T) {
133+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)), Focus: "b"}
134+ area := domain.Rect{Width: 51, Height: 2}
135+ require.NoError(t, tr.ResizeFocus(Width, 5, area))
136+ placements, ok := Solve(tr.Root, area)
137+ require.True(t, ok)
138+ require.Equal(t, map[PaneID]int{"a": 25, "b": 25}, placementWidths(placements))
139+ })
140+}
141+
142+func TestResizeFocusMinimumAndTransactionalFailures(t *testing.T) {
143+ t.Parallel()
144+
145+ minInt := -int(^uint(0)>>1) - 1
146+ tests := []struct {
147+ name string
148+ tree *Tree
149+ axis Axis
150+ delta int
151+ area domain.Rect
152+ err error
153+ want map[PaneID]int
154+ }{
155+ {
156+ name: "partial growth uses every available donor cell",
157+ tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 20), weightedLeaf("b", 22)), Focus: "a"},
158+ axis: Width,
159+ delta: 5,
160+ area: domain.Rect{Width: 43, Height: 2},
161+ want: map[PaneID]int{"a": 22, "b": 20},
162+ },
163+ {
164+ name: "minimum integer clamps without overflow",
165+ tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)), Focus: "a"},
166+ axis: Width,
167+ delta: minInt,
168+ area: domain.Rect{Width: 51, Height: 2},
169+ want: map[PaneID]int{"a": 20, "b": 30},
170+ },
171+ {
172+ name: "partial growth stops at recursive donor minimum",
173+ tree: &Tree{Root: split(Horizontal,
174+ weightedLeaf("a", 20),
175+ weightedNode(split(Horizontal, NewLeaf("b"), NewLeaf("d")), 43),
176+ ), Focus: "a"},
177+ axis: Width,
178+ delta: 5,
179+ area: domain.Rect{Width: 64, Height: 2},
180+ want: map[PaneID]int{"a": 22, "b": 20, "d": 20},
181+ },
182+ {
183+ name: "no donor capacity",
184+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
185+ axis: Width,
186+ delta: 2,
187+ area: domain.Rect{Width: 41, Height: 2},
188+ err: ErrTooSmall,
189+ },
190+ {
191+ name: "target cannot shrink",
192+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), weightedLeaf("b", 2)), Focus: "a"},
193+ axis: Width,
194+ delta: -2,
195+ area: domain.Rect{Width: 61, Height: 2},
196+ err: ErrTooSmall,
197+ },
198+ {
199+ name: "wrong axis",
200+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
201+ axis: Height,
202+ delta: 1,
203+ area: domain.Rect{Width: 41, Height: 2},
204+ err: ErrNotInSplit,
205+ },
206+ {
207+ name: "single pane",
208+ tree: NewTree("a"),
209+ axis: Width,
210+ delta: 2,
211+ area: domain.Rect{Width: 20, Height: 2},
212+ err: ErrNotInSplit,
213+ },
214+ {
215+ name: "invalid area rolls back",
216+ tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"},
217+ axis: Width,
218+ delta: 2,
219+ area: domain.Rect{Width: 20, Height: 2},
220+ err: ErrTooSmall,
221+ },
222+ }
223+
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ before := tt.tree.Clone()
227+ err := tt.tree.ResizeFocus(tt.axis, tt.delta, tt.area)
228+ if tt.err != nil {
229+ require.ErrorIs(t, err, tt.err)
230+ require.Equal(t, before, tt.tree, "failed resize must not mutate the tree")
231+ return
232+ }
233+ require.NoError(t, err)
234+ placements, ok := Solve(tt.tree.Root, tt.area)
235+ require.True(t, ok)
236+ require.Equal(t, tt.want, placementWidths(placements))
237+ })
238+ }
239+}
240+
241+func TestResizeFocusRetainsProportionsAcrossTerminalSizes(t *testing.T) {
242+ t.Parallel()
243+
244+ tr := &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}
245+ small := domain.Rect{Width: 81, Height: 2}
246+ require.NoError(t, tr.ResizeFocus(Width, 10, small))
247+
248+ placements, ok := Solve(tr.Root, domain.Rect{Width: 161, Height: 2})
249+ require.True(t, ok)
250+ require.Equal(t, map[PaneID]int{"a": 100, "b": 60}, placementWidths(placements))
251+ placements, ok = Solve(tr.Root, small)
252+ require.True(t, ok)
253+ require.Equal(t, map[PaneID]int{"a": 50, "b": 30}, placementWidths(placements))
254+}
255+
256+func TestEqualize(t *testing.T) {
257+ t.Parallel()
258+
259+ tests := []struct {
260+ name string
261+ tree *Tree
262+ area domain.Rect
263+ want map[PaneID]int
264+ }{
265+ {
266+ name: "weighted siblings become equal",
267+ tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"},
268+ area: domain.Rect{Width: 81, Height: 2},
269+ want: map[PaneID]int{"a": 40, "b": 40},
270+ },
271+ {
272+ name: "recursive minimum constrains only the large child",
273+ tree: &Tree{Root: split(Horizontal,
274+ weightedNode(split(Horizontal, weightedLeaf("a", 5), weightedLeaf("b", 1)), 9),
275+ weightedLeaf("c", 1), weightedLeaf("d", 1),
276+ ), Focus: "a"},
277+ area: domain.Rect{Width: 102, Height: 2},
278+ want: map[PaneID]int{"a": 20, "b": 20, "c": 30, "d": 29},
279+ },
280+ }
281+
282+ for _, tt := range tests {
283+ t.Run(tt.name, func(t *testing.T) {
284+ require.NoError(t, tt.tree.Equalize(tt.area))
285+ placements, ok := Solve(tt.tree.Root, tt.area)
286+ require.True(t, ok)
287+ require.Equal(t, tt.want, placementWidths(placements))
288+ })
289+ }
290+}
291+
292+func TestEqualizeFailureIsTransactional(t *testing.T) {
293+ t.Parallel()
294+
295+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
296+ before := tr.Clone()
297+ require.ErrorIs(t, tr.Equalize(domain.Rect{Width: 20, Height: 2}), ErrTooSmall)
298+ require.Equal(t, before, tr)
299+}
300+
301+func TestCanResize(t *testing.T) {
302+ t.Parallel()
303+
304+ tests := []struct {
305+ name string
306+ tree *Tree
307+ want bool
308+ }{
309+ {name: "single pane", tree: NewTree("a"), want: false},
310+ {name: "focused split child", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, want: true},
311+ {name: "focused stack member under split", tree: &Tree{Root: split(Horizontal, &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, NewLeaf("c")), Focus: "b"}, want: true},
312+ {name: "stack without split", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, Focus: "a"}, want: false},
313+ {name: "missing focus", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "missing"}, want: false},
314+ }
315+
316+ for _, tt := range tests {
317+ t.Run(tt.name, func(t *testing.T) {
318+ require.Equal(t, tt.want, tt.tree.CanResize())
319+ })
320+ }
321+}
M internal/usecase/layout/tree.go +9 -0
@@ -37,6 +37,14 @@ const (
37 MinPaneRows = 237 MinPaneRows = 2
38 )38 )
39 39
40+// Axis identifies the dimension changed by a resize operation.
41+type Axis int
42+
43+const (
44+ Width Axis = iota
45+ Height
46+)
47+
40 // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir48 // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir
41 // and Children are meaningful for Split nodes; Children and Expanded are49 // and Children are meaningful for Split nodes; Children and Expanded are
42 // meaningful for Stack nodes.50 // meaningful for Stack nodes.
@@ -46,6 +54,7 @@ type Node struct {
46 Children []*Node54 Children []*Node
47 Leaf PaneID55 Leaf PaneID
48 Expanded PaneID56 Expanded PaneID
57+ Weight float64
49 }58 }
50 59
51 // Tree is a pane layout plus its focused pane.60 // Tree is a pane layout plus its focused pane.
A internal/usecase/layout/weights_test.go +363 -0
@@ -0,0 +1,363 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestWeightedSplitAllocation(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ root *Node
16+ area domain.Rect
17+ want map[PaneID]int
18+ }{
19+ {
20+ name: "zero weights preserve equal split and remainder order",
21+ root: split(Horizontal, NewLeaf("a"), NewLeaf("b"), NewLeaf("c")),
22+ area: domain.Rect{Width: 64, Height: 2},
23+ want: map[PaneID]int{"a": 21, "b": 21, "c": 20},
24+ },
25+ {
26+ name: "two to one weights",
27+ root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)),
28+ area: domain.Rect{Width: 61, Height: 2},
29+ want: map[PaneID]int{"a": 40, "b": 20},
30+ },
31+ {
32+ name: "largest remainder ties use child order",
33+ root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 2)),
34+ area: domain.Rect{Width: 84, Height: 2},
35+ want: map[PaneID]int{"a": 21, "b": 20, "c": 41},
36+ },
37+ {
38+ name: "minimum constrained children pin and remainder redistributes proportionally",
39+ root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 8)),
40+ area: domain.Rect{Width: 102, Height: 2},
41+ want: map[PaneID]int{"a": 20, "b": 20, "c": 60},
42+ },
43+ {
44+ name: "negative and zero in memory weights use default",
45+ root: split(Horizontal, weightedLeaf("a", -4), weightedLeaf("b", 0), weightedLeaf("c", 2)),
46+ area: domain.Rect{Width: 82, Height: 2},
47+ want: map[PaneID]int{"a": 20, "b": 20, "c": 40},
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ placements, ok := Solve(tt.root, tt.area)
54+ require.True(t, ok)
55+ for id, want := range tt.want {
56+ require.Equal(t, want, contents(placements)[id].Width)
57+ }
58+ })
59+ }
60+}
61+
62+func TestDistributePinsChildrenFromOneQuotaSnapshot(t *testing.T) {
63+ t.Parallel()
64+
65+ allocations, ok := distribute(100, []int{20, 33, 42}, []float64{1, 4, 5})
66+ require.True(t, ok)
67+ require.Equal(t, []int{20, 36, 44}, allocations)
68+ require.Equal(t, 100, allocations[0]+allocations[1]+allocations[2])
69+}
70+
71+func TestWeightedSplitNestedMinimumRedistributesWithFreshDenominator(t *testing.T) {
72+ t.Parallel()
73+
74+ root := split(Horizontal,
75+ weightedNode(split(Horizontal, NewLeaf("a"), NewLeaf("b")), 2),
76+ weightedLeaf("c", 2),
77+ weightedLeaf("d", 6),
78+ )
79+
80+ rects, ok := splitChildRects(root, domain.Rect{Width: 132, Height: 2})
81+ require.True(t, ok)
82+ require.Equal(t, []int{41, 22, 67}, []int{rects[0].Width, rects[1].Width, rects[2].Width})
83+}
84+
85+func TestWeightedSplitRecursiveMinima(t *testing.T) {
86+ t.Parallel()
87+
88+ tests := []struct {
89+ name string
90+ root *Node
91+ area domain.Rect
92+ wantOK bool
93+ want map[PaneID]domain.Rect
94+ }{
95+ {
96+ name: "same axis nested minima sum children and separators",
97+ root: split(Horizontal,
98+ weightedNode(split(Horizontal, NewLeaf("a"), NewLeaf("b")), 1),
99+ weightedLeaf("c", 9),
100+ ),
101+ area: domain.Rect{Width: 102, Height: 2},
102+ wantOK: true,
103+ want: map[PaneID]domain.Rect{
104+ "a": {Width: 20, Height: 2},
105+ "b": {X: 21, Width: 20, Height: 2},
106+ "c": {X: 42, Width: 60, Height: 2},
107+ },
108+ },
109+ {
110+ name: "cross axis nested minima take maximum",
111+ root: split(Horizontal,
112+ split(Vertical, NewLeaf("a"), NewLeaf("b")),
113+ NewLeaf("c"),
114+ ),
115+ area: domain.Rect{Width: 41, Height: 5},
116+ wantOK: true,
117+ },
118+ {
119+ name: "stack vertical minimum is titles plus one content row",
120+ root: split(Vertical,
121+ weightedNode(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}, 1),
122+ weightedLeaf("d", 9),
123+ ),
124+ area: domain.Rect{Width: 20, Height: 7},
125+ wantOK: true,
126+ },
127+ {
128+ name: "insufficient same axis recursive minimum fails without placements",
129+ root: split(Horizontal,
130+ split(Horizontal, NewLeaf("a"), NewLeaf("b")),
131+ NewLeaf("c"),
132+ ),
133+ area: domain.Rect{Width: 61, Height: 2},
134+ wantOK: false,
135+ },
136+ {
137+ name: "insufficient cross axis recursive minimum fails without placements",
138+ root: split(Horizontal,
139+ split(Vertical, NewLeaf("a"), NewLeaf("b")),
140+ NewLeaf("c"),
141+ ),
142+ area: domain.Rect{Width: 41, Height: 4},
143+ wantOK: false,
144+ },
145+ }
146+
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ placements, ok := Solve(tt.root, tt.area)
150+ require.Equal(t, tt.wantOK, ok)
151+ if !tt.wantOK {
152+ require.Nil(t, placements)
153+ return
154+ }
155+ if tt.want != nil {
156+ require.Equal(t, tt.want, contents(placements))
157+ }
158+ })
159+ }
160+}
161+
162+func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T) {
163+ t.Parallel()
164+
165+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
166+ area := domain.Rect{Width: 82, Height: 2}
167+ require.NoError(t, tr.Split("b", Right, true, "c", area))
168+
169+ require.Equal(t, 54.0, tr.Root.Children[0].Weight)
170+ require.Equal(t, 27.0, tr.Root.Children[1].Weight)
171+ require.Zero(t, tr.Root.Children[2].Weight)
172+ placements, ok := Solve(tr.Root, area)
173+ require.True(t, ok)
174+ require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements))
175+}
176+
177+func TestWeightCrossAxisSplitTransfersParentShareAndClearsMembers(t *testing.T) {
178+ t.Parallel()
179+
180+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
181+ require.NoError(t, tr.Split("a", Down, true, "c", domain.Rect{Width: 81, Height: 5}))
182+
183+ wrapper := tr.Root.Children[0]
184+ require.Equal(t, Vertical, wrapper.Dir)
185+ require.Equal(t, 3.0, wrapper.Weight)
186+ require.Zero(t, wrapper.Children[0].Weight)
187+ require.Zero(t, wrapper.Children[1].Weight)
188+ placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 5})
189+ require.True(t, ok)
190+ require.Equal(t, 60, contents(placements)["a"].Width)
191+ require.Equal(t, 20, contents(placements)["b"].Width)
192+}
193+
194+func TestWeightStackNewTransfersParentShareAndClearsMembers(t *testing.T) {
195+ t.Parallel()
196+
197+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}
198+ require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 81, Height: 3}))
199+
200+ stackNode := tr.Root.Children[0]
201+ require.Equal(t, Stack, stackNode.Kind)
202+ require.Equal(t, 3.0, stackNode.Weight)
203+ require.Zero(t, stackNode.Children[0].Weight)
204+ require.Zero(t, stackNode.Children[1].Weight)
205+ placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 3})
206+ require.True(t, ok)
207+ require.Equal(t, 60, contents(placements)["c"].Width)
208+ require.Equal(t, 20, contents(placements)["b"].Width)
209+}
210+
211+func TestWeightStackNewClearsExistingStackMemberWeights(t *testing.T) {
212+ t.Parallel()
213+
214+ tr := &Tree{Root: &Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 3), weightedLeaf("b", 1)}, Expanded: "a", Weight: 4}, Focus: "a"}
215+ require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 20, Height: 4}))
216+
217+ require.Equal(t, 4.0, tr.Root.Weight)
218+ for _, child := range tr.Root.Children {
219+ require.Zero(t, child.Weight)
220+ }
221+}
222+
223+func TestWeightToggleStackClearsMemberWeights(t *testing.T) {
224+ t.Parallel()
225+
226+ tests := []struct {
227+ name string
228+ root *Node
229+ wantKind Kind
230+ }{
231+ {
232+ name: "split to stack",
233+ root: &Node{Kind: Split, Dir: Vertical, Weight: 3, Children: []*Node{
234+ weightedLeaf("a", 2), weightedLeaf("b", 1),
235+ }},
236+ wantKind: Stack,
237+ },
238+ {
239+ name: "stack to split",
240+ root: &Node{Kind: Stack, Weight: 3, Children: []*Node{
241+ weightedLeaf("a", 2), weightedLeaf("b", 1),
242+ }, Expanded: "a"},
243+ wantKind: Split,
244+ },
245+ }
246+
247+ for _, tt := range tests {
248+ t.Run(tt.name, func(t *testing.T) {
249+ tr := &Tree{Root: split(Horizontal, tt.root, weightedLeaf("c", 1)), Focus: "a"}
250+ area := domain.Rect{Width: 81, Height: 5}
251+ require.NoError(t, tr.ToggleStack("a", area))
252+ container := tr.Root.Children[0]
253+ require.Equal(t, tt.wantKind, container.Kind)
254+ require.Equal(t, 3.0, container.Weight)
255+ for _, child := range container.Children {
256+ require.Zero(t, child.Weight)
257+ }
258+ placements, ok := Solve(tr.Root, area)
259+ require.True(t, ok)
260+ require.Equal(t, 60, contents(placements)["a"].Width)
261+ require.Equal(t, 20, contents(placements)["c"].Width)
262+ if tt.wantKind == Split {
263+ require.Equal(t, map[PaneID]int{"a": 2, "b": 2, "c": 5}, placementHeights(placements))
264+ }
265+ })
266+ }
267+}
268+
269+func TestWeightClosePromotionTransfersContainerShare(t *testing.T) {
270+ t.Parallel()
271+
272+ tests := []struct {
273+ name string
274+ container *Node
275+ }{
276+ {
277+ name: "split",
278+ container: split(Vertical, NewLeaf("a"), NewLeaf("b")),
279+ },
280+ {
281+ name: "stack",
282+ container: &Node{Kind: Stack, Children: []*Node{
283+ NewLeaf("a"), NewLeaf("b"),
284+ }, Expanded: "a"},
285+ },
286+ }
287+
288+ for _, tt := range tests {
289+ t.Run(tt.name, func(t *testing.T) {
290+ tr := &Tree{Root: split(Horizontal,
291+ weightedNode(tt.container, 3),
292+ weightedLeaf("c", 1),
293+ ), Focus: "a"}
294+ area := domain.Rect{Width: 81, Height: 5}
295+ before, ok := Solve(tr.Root, area)
296+ require.True(t, ok)
297+ require.Equal(t, 60, contents(before)["a"].Width)
298+
299+ require.NoError(t, tr.Close("a"))
300+ require.Equal(t, PaneID("b"), tr.Root.Children[0].Leaf)
301+ require.Equal(t, 3.0, tr.Root.Children[0].Weight)
302+ after, ok := Solve(tr.Root, area)
303+ require.True(t, ok)
304+ require.Equal(t, 60, contents(after)["b"].Width)
305+ require.Equal(t, 20, contents(after)["c"].Width)
306+ })
307+ }
308+}
309+
310+func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
311+ t.Parallel()
312+
313+ tr := &Tree{Root: &Node{Kind: Stack, Weight: 4, Children: []*Node{
314+ weightedLeaf("a", 2), weightedLeaf("b", 1),
315+ }, Expanded: "a"}, Focus: "a"}
316+ require.NoError(t, tr.Split("a", Right, true, "c", domain.Rect{Width: 41, Height: 3}))
317+
318+ require.Equal(t, 4.0, tr.Root.Weight)
319+ require.Zero(t, tr.Root.Children[0].Weight)
320+ require.Equal(t, Stack, tr.Root.Children[0].Kind)
321+ for _, member := range tr.Root.Children[0].Children {
322+ require.Zero(t, member.Weight)
323+ }
324+}
325+
326+func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
327+ t.Parallel()
328+
329+ tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
330+ clone := tr.Clone()
331+ require.Equal(t, tr, clone)
332+ clone.Root.Children[0].Weight = 9
333+ require.Equal(t, 2.0, tr.Root.Children[0].Weight)
334+}
335+
336+func split(dir SplitDir, children ...*Node) *Node {
337+ return &Node{Kind: Split, Dir: dir, Children: children}
338+}
339+
340+func weightedLeaf(id PaneID, weight float64) *Node {
341+ return &Node{Kind: Leaf, Leaf: id, Weight: weight}
342+}
343+
344+func weightedNode(n *Node, weight float64) *Node {
345+ n.Weight = weight
346+ return n
347+}
348+
349+func placementWidths(placements []Placement) map[PaneID]int {
350+ out := make(map[PaneID]int, len(placements))
351+ for _, placement := range placements {
352+ out[placement.ID] = placement.Content.Width
353+ }
354+ return out
355+}
356+
357+func placementHeights(placements []Placement) map[PaneID]int {
358+ out := make(map[PaneID]int, len(placements))
359+ for _, placement := range placements {
360+ out[placement.ID] = placement.Content.Height
361+ }
362+ return out
363+}
M internal/usecase/snapshot/manifest.go +106 -1
@@ -29,6 +29,9 @@ const (
29 29
30 processAbsent uint8 = 030 processAbsent uint8 = 0
31 processPresent uint8 = 131 processPresent uint8 = 1
32+
33+ manifestWeightTag = "WGT1"
34+ manifestWeightSize = 8
32 // maxDecodedBodySize includes the object kind and payload-length prefix.35 // maxDecodedBodySize includes the object kind and payload-length prefix.
33 maxObjectEnvelopeSize = manifestHeaderSize + maxDecodedBodySize36 maxObjectEnvelopeSize = manifestHeaderSize + maxDecodedBodySize
34 maxObjectPayloadSize = maxDecodedBodySize - objectEnvelopeBodyPrefixSize37 maxObjectPayloadSize = maxDecodedBodySize - objectEnvelopeBodyPrefixSize
@@ -140,6 +143,9 @@ func MarshalManifest(m Manifest) ([]byte, error) {
140 return nil, err143 return nil, err
141 }144 }
142 }145 }
146+ if err := writeManifestWeights(&w, manifestNodes(m)); err != nil {
147+ return nil, err
148+ }
143 return marshalManifestEnvelope(manifestMagic, w.b)149 return marshalManifestEnvelope(manifestMagic, w.b)
144 }150 }
145 151
@@ -184,7 +190,7 @@ func decodeManifest(body []byte) (Manifest, error) {
184 return Manifest{}, err190 return Manifest{}, err
185 }191 }
186 }192 }
187- if err := r.done(); err != nil {193+ if err := readManifestWeights(&r, manifestNodes(m)); err != nil {
188 return Manifest{}, err194 return Manifest{}, err
189 }195 }
190 if err := validateManifest(m); err != nil {196 if err := validateManifest(m); err != nil {
@@ -316,6 +322,100 @@ func writeManifestTab(w *payloadWriter, tab ManifestTab) error {
316 }322 }
317 return nil323 return nil
318 }324 }
325+func writeManifestWeights(w *payloadWriter, nodes []*layout.Node) error {
326+ hasWeights := false
327+ for _, node := range nodes {
328+ if node.Weight != 0 {
329+ hasWeights = true
330+ break
331+ }
332+ }
333+ if !hasWeights {
334+ return nil
335+ }
336+ if len(nodes) > math.MaxUint32 {
337+ return fmt.Errorf("%w: too many weighted nodes", ErrInvalidData)
338+ }
339+ w.b = append(w.b, manifestWeightTag...)
340+ w.putUint32(uint32(len(nodes)))
341+ for _, node := range nodes {
342+ w.putUint64(math.Float64bits(node.Weight))
343+ }
344+ return nil
345+}
346+
347+func readManifestWeights(r *payloadReader, nodes []*layout.Node) error {
348+ return readManifestWeightExtension(r, uint64(len(nodes)), func(i int, weight float64) {
349+ nodes[i].Weight = weight
350+ })
351+}
352+
353+// readManifestWeightExtension validates the optional weight extension and
354+// calls assign for each decoded weight when assignment is required.
355+func readManifestWeightExtension(r *payloadReader, wantNodes uint64, assign func(int, float64)) error {
356+ if len(r.b) == 0 {
357+ return nil
358+ }
359+ if len(r.b) < len(manifestWeightTag) {
360+ return ErrShortPayload
361+ }
362+ if string(r.b[:len(manifestWeightTag)]) != manifestWeightTag {
363+ return fmt.Errorf("%w: weight extension tag", ErrInvalidData)
364+ }
365+ r.b = r.b[len(manifestWeightTag):]
366+ count, err := r.getUint32()
367+ if err != nil {
368+ return err
369+ }
370+ if uint64(count) != wantNodes {
371+ return fmt.Errorf("%w: weight node count", ErrInvalidData)
372+ }
373+ if uint64(count) > uint64(len(r.b))/manifestWeightSize {
374+ return ErrShortPayload
375+ }
376+ if uint64(len(r.b)) != uint64(count)*manifestWeightSize {
377+ return ErrTrailingBytes
378+ }
379+ for i := range count {
380+ bits, err := r.getUint64()
381+ if err != nil {
382+ return err
383+ }
384+ weight := math.Float64frombits(bits)
385+ if !validManifestWeight(weight) {
386+ return fmt.Errorf("%w: node weight", ErrInvalidData)
387+ }
388+ if assign != nil {
389+ assign(int(i), weight)
390+ }
391+ }
392+ return r.done()
393+}
394+
395+func manifestNodes(m Manifest) []*layout.Node {
396+ var nodes []*layout.Node
397+ var visit func(*layout.Node)
398+ visit = func(node *layout.Node) {
399+ if node == nil {
400+ return
401+ }
402+ nodes = append(nodes, node)
403+ for _, child := range node.Children {
404+ visit(child)
405+ }
406+ }
407+ for _, tab := range m.Tabs {
408+ if tab.Tree != nil {
409+ visit(tab.Tree.Root)
410+ }
411+ }
412+ return nodes
413+}
414+
415+func validManifestWeight(weight float64) bool {
416+ return weight >= 0 && !math.IsNaN(weight) && !math.IsInf(weight, 0)
417+}
418+
319 func writeManifestPane(w *payloadWriter, pane ManifestPane) error {419 func writeManifestPane(w *payloadWriter, pane ManifestPane) error {
320 if err := w.putString(string(pane.ID)); err != nil {420 if err := w.putString(string(pane.ID)); err != nil {
321 return err421 return err
@@ -448,6 +548,11 @@ func validateManifest(m Manifest) error {
448 if err := validateActive(m.Active, len(m.Tabs)); err != nil {548 if err := validateActive(m.Active, len(m.Tabs)); err != nil {
449 return err549 return err
450 }550 }
551+ for _, node := range manifestNodes(m) {
552+ if !validManifestWeight(node.Weight) {
553+ return fmt.Errorf("%w: node weight", ErrInvalidData)
554+ }
555+ }
451 for _, tab := range m.Tabs {556 for _, tab := range m.Tabs {
452 if !safeDimensions(tab.Cols, tab.Rows) {557 if !safeDimensions(tab.Cols, tab.Rows) {
453 return fmt.Errorf("%w: dimensions", ErrInvalidData)558 return fmt.Errorf("%w: dimensions", ErrInvalidData)
M internal/usecase/snapshot/manifest_preflight.go +11 -2
@@ -43,10 +43,13 @@ func preflightManifest(body []byte) error {
43 return err43 return err
44 }44 }
45 }45 }
46- return r.done()46+ return preflightManifestWeights(&r, budget.nodes)
47 }47 }
48 48
49-type manifestPreflightBudget struct{ allocation uint64 }49+type manifestPreflightBudget struct {
50+ allocation uint64
51+ nodes uint32
52+}
50 53
51 func (b *manifestPreflightBudget) add(n uint64) bool {54 func (b *manifestPreflightBudget) add(n uint64) bool {
52 if n > maxSnapshotDecodedAllocation-b.allocation {55 if n > maxSnapshotDecodedAllocation-b.allocation {
@@ -158,6 +161,7 @@ func preflightManifestNode(r *payloadReader, depth int, root bool, budget *manif
158 if err != nil {161 if err != nil {
159 return err162 return err
160 }163 }
164+ budget.nodes++
161 *refs = append(*refs, layout.PaneID(leaf))165 *refs = append(*refs, layout.PaneID(leaf))
162 return nil166 return nil
163 case manifestNodeSplit:167 case manifestNodeSplit:
@@ -184,6 +188,7 @@ func preflightManifestNode(r *payloadReader, depth int, root bool, budget *manif
184 default:188 default:
185 return fmt.Errorf("%w: node kind", ErrInvalidData)189 return fmt.Errorf("%w: node kind", ErrInvalidData)
186 }190 }
191+ budget.nodes++
187 n, err := r.getUint16()192 n, err := r.getUint16()
188 if err != nil {193 if err != nil {
189 return err194 return err
@@ -199,6 +204,10 @@ func preflightManifestNode(r *payloadReader, depth int, root bool, budget *manif
199 return nil204 return nil
200 }205 }
201 206
207+func preflightManifestWeights(r *payloadReader, wantNodes uint32) error {
208+ return readManifestWeightExtension(r, uint64(wantNodes), nil)
209+}
210+
202 func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (layout.PaneID, error) {211 func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (layout.PaneID, error) {
203 id, err := preflightManifestString(r, budget)212 id, err := preflightManifestString(r, budget)
204 if err != nil {213 if err != nil {
A internal/usecase/snapshot/weights_test.go +211 -0
@@ -0,0 +1,211 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "encoding/hex"
7+ "math"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+func TestManifestWeightRoundTripAndOldCompatibility(t *testing.T) {
14+ weighted := testManifest(&layout.Tree{Root: &layout.Node{
15+ Kind: layout.Split,
16+ Dir: layout.Horizontal,
17+ Children: []*layout.Node{
18+ {Kind: layout.Leaf, Leaf: "one", Weight: 2},
19+ {Kind: layout.Leaf, Leaf: "two", Weight: 1},
20+ },
21+ }}, nil)
22+
23+ encoded, err := MarshalManifest(weighted)
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ weightedBody, err := unmarshalManifestEnvelope(encoded, manifestMagic)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ extensionSize := len(manifestWeightTag) + 4 + 3*manifestWeightSize
32+ if len(weightedBody) < extensionSize {
33+ t.Fatalf("weighted body length = %d, want at least %d", len(weightedBody), extensionSize)
34+ }
35+ unweightedBody := weightedBody[:len(weightedBody)-extensionSize]
36+ extension := weightedBody[len(unweightedBody):]
37+ if string(extension[:len(manifestWeightTag)]) != manifestWeightTag || binary.BigEndian.Uint32(extension[len(manifestWeightTag):]) != 3 {
38+ t.Fatalf("weight extension header = %x", extension[:len(manifestWeightTag)+4])
39+ }
40+ for i, want := range []float64{0, 2, 1} {
41+ offset := len(manifestWeightTag) + 4 + i*manifestWeightSize
42+ if got := math.Float64frombits(binary.BigEndian.Uint64(extension[offset:])); got != want {
43+ t.Fatalf("preorder weight %d = %v, want %v", i, got, want)
44+ }
45+ }
46+
47+ got, err := UnmarshalManifest(encoded)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if got.Tabs[0].Tree.Root.Weight != 0 || got.Tabs[0].Tree.Root.Children[0].Weight != 2 || got.Tabs[0].Tree.Root.Children[1].Weight != 1 {
52+ t.Fatalf("decoded weights = root %v, children %v/%v", got.Tabs[0].Tree.Root.Weight, got.Tabs[0].Tree.Root.Children[0].Weight, got.Tabs[0].Tree.Root.Children[1].Weight)
53+ }
54+
55+ unweighted := testManifest(&layout.Tree{Root: &layout.Node{
56+ Kind: layout.Split,
57+ Dir: layout.Horizontal,
58+ Children: []*layout.Node{
59+ layout.NewLeaf("one"),
60+ layout.NewLeaf("two"),
61+ },
62+ }}, nil)
63+ oldEncoded, err := MarshalManifest(unweighted)
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ oldBody, err := unmarshalManifestEnvelope(oldEncoded, manifestMagic)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ if !bytes.Equal(oldBody, unweightedBody) {
72+ t.Fatalf("weighted manifest changed the legacy body before its extension")
73+ }
74+ oldDecoded, err := UnmarshalManifest(oldEncoded)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ for _, node := range manifestNodes(oldDecoded) {
79+ if node.Weight != 0 {
80+ t.Fatalf("old manifest decoded weight = %v, want zero", node.Weight)
81+ }
82+ }
83+}
84+
85+func TestSnapshotEnvelopeCompatibility(t *testing.T) {
86+ const (
87+ headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"
88+ objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"
89+ manifestGolden = "5645564d0001000000000122f8f8f0af000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
90+ )
91+ if ManifestVersion != 1 {
92+ t.Fatalf("ManifestVersion = %d, want 1", ManifestVersion)
93+ }
94+
95+ head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ object, err := MarshalObject(Visible, []byte("canonical-vt"))
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ manifest, err := MarshalManifest(unweightedTestManifest())
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ for _, tc := range []struct {
108+ name string
109+ got []byte
110+ golden string
111+ }{
112+ {name: "HEAD", got: head, golden: headGolden},
113+ {name: "object", got: object.Data, golden: objectGolden},
114+ {name: "unweighted manifest", got: manifest, golden: manifestGolden},
115+ } {
116+ t.Run(tc.name, func(t *testing.T) {
117+ want, err := hex.DecodeString(tc.golden)
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ if !bytes.Equal(tc.got, want) {
122+ t.Fatalf("bytes changed:\n got %x\nwant %x", tc.got, want)
123+ }
124+ })
125+ }
126+}
127+
128+func TestManifestWeightExtensionMalformedCorpus(t *testing.T) {
129+ oldEncoded, err := MarshalManifest(unweightedTestManifest())
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ oldBody, err := unmarshalManifestEnvelope(oldEncoded, manifestMagic)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+
138+ weighted := unweightedTestManifest()
139+ weighted.Tabs[0].Tree.Root.Children[0].Weight = 2
140+ weightedEncoded, err := MarshalManifest(weighted)
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ weightedBody, err := unmarshalManifestEnvelope(weightedEncoded, manifestMagic)
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ extensionOffset := len(oldBody)
149+ if len(weightedBody) <= extensionOffset || string(weightedBody[extensionOffset:extensionOffset+len(manifestWeightTag)]) != manifestWeightTag {
150+ t.Fatalf("weighted body has no %q extension", manifestWeightTag)
151+ }
152+
153+ mutate := func(fn func([]byte)) []byte {
154+ body := append([]byte(nil), weightedBody...)
155+ fn(body)
156+ return body
157+ }
158+ firstWeight := extensionOffset + len(manifestWeightTag) + 4
159+ corpus := []struct {
160+ name string
161+ body []byte
162+ valid bool
163+ }{
164+ {name: "legacy without extension", body: oldBody, valid: true},
165+ {name: "valid extension", body: weightedBody, valid: true},
166+ {name: "malformed tag", body: mutate(func(body []byte) { body[extensionOffset] ^= 0xff })},
167+ {name: "node count mismatch", body: mutate(func(body []byte) { binary.BigEndian.PutUint32(body[extensionOffset+len(manifestWeightTag):], 2) })},
168+ {name: "truncated weight", body: weightedBody[:len(weightedBody)-1]},
169+ {name: "trailing garbage", body: append(append([]byte(nil), weightedBody...), 0)},
170+ {name: "negative weight", body: mutate(func(body []byte) { binary.BigEndian.PutUint64(body[firstWeight:], math.Float64bits(-1)) })},
171+ {name: "NaN weight", body: mutate(func(body []byte) { binary.BigEndian.PutUint64(body[firstWeight:], math.Float64bits(math.NaN())) })},
172+ {name: "infinite weight", body: mutate(func(body []byte) { binary.BigEndian.PutUint64(body[firstWeight:], math.Float64bits(math.Inf(1))) })},
173+ }
174+
175+ for _, tc := range corpus {
176+ t.Run(tc.name, func(t *testing.T) {
177+ preflightErr := preflightManifest(tc.body)
178+ _, decodeErr := decodeManifest(tc.body)
179+ if (preflightErr == nil) != tc.valid {
180+ t.Fatalf("preflight error = %v, valid=%v", preflightErr, tc.valid)
181+ }
182+ if (decodeErr == nil) != tc.valid {
183+ t.Fatalf("decode error = %v, valid=%v", decodeErr, tc.valid)
184+ }
185+ if (preflightErr == nil) != (decodeErr == nil) {
186+ t.Fatalf("preflight/decode disagreement: %v / %v", preflightErr, decodeErr)
187+ }
188+ })
189+ }
190+}
191+
192+func TestMarshalManifestRejectsInvalidWeights(t *testing.T) {
193+ for _, weight := range []float64{-1, math.NaN(), math.Inf(1), math.Inf(-1)} {
194+ manifest := unweightedTestManifest()
195+ manifest.Tabs[0].Tree.Root.Weight = weight
196+ if _, err := MarshalManifest(manifest); err == nil {
197+ t.Fatalf("MarshalManifest() accepted weight %v", weight)
198+ }
199+ }
200+}
201+
202+func unweightedTestManifest() Manifest {
203+ return testManifest(&layout.Tree{Root: &layout.Node{
204+ Kind: layout.Split,
205+ Dir: layout.Horizontal,
206+ Children: []*layout.Node{
207+ layout.NewLeaf("one"),
208+ layout.NewLeaf("two"),
209+ },
210+ }}, nil)
211+}

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