Merge pull request #132 from bnema/feat/pane-resize
feat: resize active panes safely
44 files · +4387 -417
M internal/app/cmd_test.go +41 -3
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | func TestParseCmdArgs(t *testing.T) { | |
| @@ -23,6 +24,8 @@ func TestParseCmdArgs(t *testing.T) { | |||
| 23 | 24 | {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false}, | |
| 24 | 25 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | |
| 25 | 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 | 29 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | |
| 27 | 30 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | |
| 28 | 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 | 93 | if called { | |
| 91 | 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 | 97 | if !strings.Contains(out.String(), want) { | |
| 95 | 98 | t.Fatalf("help %q missing %q", out.String(), want) | |
| 96 | 99 | } | |
| 97 | 100 | } | |
| 98 | - | if strings.Contains(out.String(), "session-picker") { | |
| 99 | - | t.Fatalf("help leaked non-scriptable command: %q", out.String()) | |
| 101 | + | for _, hidden := range []string{"session-picker", "resize-pane"} { | |
| 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 | 249 | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"}, | |
| 222 | 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 | 263 | for _, tt := range tests { | |
| 226 | 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 | 184 | return ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil | |
| 185 | 185 | } | |
| 186 | 186 | t.Cleanup(func() { newPerformanceTrace = original }) | |
| 187 | + | t.Setenv("VEV", "") | |
| 187 | 188 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 188 | 189 | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 189 | 190 | t.Setenv(envRemoteTransport, "invalid") | |
| 190 | 191 | ||
| 191 | 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 | 196 | if !errors.Is(err, traceCloseErr) { | |
| 193 | 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 | 207 | func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) } | |
| 204 | 208 | ||
| 205 | 209 | func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) { | |
| 210 | + | t.Setenv("VEV", "") | |
| 206 | 211 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 207 | 212 | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 208 | 213 | t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio)) | |
M internal/usecase/command/command.go +11 -0
| @@ -43,6 +43,12 @@ type Context interface { | |||
| 43 | 43 | FocusPaneRight() error | |
| 44 | 44 | FocusPaneUp() error | |
| 45 | 45 | FocusPaneDown() error | |
| 46 | + | EnterResizeMode() error | |
| 47 | + | GrowPaneWidth() error | |
| 48 | + | ShrinkPaneWidth() error | |
| 49 | + | GrowPaneHeight() error | |
| 50 | + | ShrinkPaneHeight() error | |
| 51 | + | EqualizePanes() error | |
| 46 | 52 | NextTab() error | |
| 47 | 53 | PrevTab() error | |
| 48 | 54 | BackSession() error | |
| @@ -104,6 +110,11 @@ type ControlContext interface { | |||
| 104 | 110 | SplitDown() error | |
| 105 | 111 | StackPane() error | |
| 106 | 112 | ToggleStack() error | |
| 113 | + | GrowPaneWidth() error | |
| 114 | + | ShrinkPaneWidth() error | |
| 115 | + | GrowPaneHeight() error | |
| 116 | + | ShrinkPaneHeight() error | |
| 117 | + | EqualizePanes() error | |
| 107 | 118 | FocusPaneLeft() error | |
| 108 | 119 | FocusPaneRight() error | |
| 109 | 120 | 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 | 299 | 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 | 346 | // EnterVisualMode provides a mock function for the type MockContext | |
| 303 | 347 | func (_mock *MockContext) EnterVisualMode() error { | |
| 304 | 348 | ret := _mock.Called() | |
| @@ -343,6 +387,50 @@ func (_c *MockContext_EnterVisualMode_Call) RunAndReturn(run func() error) *Mock | |||
| 343 | 387 | 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 | 434 | // FocusPaneDown provides a mock function for the type MockContext | |
| 347 | 435 | func (_mock *MockContext) FocusPaneDown() error { | |
| 348 | 436 | ret := _mock.Called() | |
| @@ -519,6 +607,94 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont | |||
| 519 | 607 | 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 | 698 | // JumpRecentSession provides a mock function for the type MockContext | |
| 523 | 699 | func (_mock *MockContext) JumpRecentSession(rank int) error { | |
| 524 | 700 | ret := _mock.Called(rank) | |
| @@ -834,6 +1010,94 @@ func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContex | |||
| 834 | 1010 | 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 | 1101 | // SplitDown provides a mock function for the type MockContext | |
| 838 | 1102 | func (_mock *MockContext) SplitDown() error { | |
| 839 | 1103 | ret := _mock.Called() | |
M internal/usecase/command/registry.go +6 -0
| @@ -20,6 +20,12 @@ func Registry() []Command { | |||
| 20 | 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 | 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 | 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 | 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 | 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 | 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 | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 11 | 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 | 14 | if len(commands) != len(wantCodes) { | |
| 15 | 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -68,11 +68,13 @@ func TestRegistryControlMetadata(t *testing.T) { | |||
| 68 | 68 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | |
| 69 | 69 | "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab, | |
| 70 | 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 | 73 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | |
| 72 | 74 | } | |
| 73 | 75 | notScriptable := []string{ | |
| 74 | 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 | 79 | for slug, target := range scriptable { | |
| 78 | 80 | cmd, ok := BySlug(slug) | |
| @@ -115,6 +117,13 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||
| 115 | 117 | }{ | |
| 116 | 118 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | |
| 117 | 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 | 127 | {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"}, | |
| 119 | 128 | {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments}, | |
| 120 | 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 | 162 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | |
| 154 | 163 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | |
| 155 | 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 | 170 | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | |
| 157 | 171 | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | |
| 158 | 172 | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | |
| @@ -244,6 +258,12 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 244 | 258 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | |
| 245 | 259 | {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }}, | |
| 246 | 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 | 267 | {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }}, | |
| 248 | 268 | {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }}, | |
| 249 | 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 | 70 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | |
| 71 | 71 | case errors.Is(err, errSessionNameInUse): | |
| 72 | 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 | 77 | default: | |
| 74 | 78 | return commandFailure(ports.ErrInternal, err.Error()) | |
| 75 | 79 | } | |
| @@ -198,6 +202,8 @@ const ( | |||
| 198 | 202 | daemonActionPreviousTab | |
| 199 | 203 | daemonActionRenameSession | |
| 200 | 204 | daemonActionRenameTab | |
| 205 | + | daemonActionResizePane | |
| 206 | + | daemonActionEqualizePanes | |
| 201 | 207 | ) | |
| 202 | 208 | ||
| 203 | 209 | type daemonActionTarget struct { | |
| @@ -210,6 +216,8 @@ type daemonActionRequest struct { | |||
| 210 | 216 | kind daemonActionKind | |
| 211 | 217 | target daemonActionTarget | |
| 212 | 218 | direction layout.Direction | |
| 219 | + | axis layout.Axis | |
| 220 | + | delta int | |
| 213 | 221 | viewport domain.Size | |
| 214 | 222 | name string | |
| 215 | 223 | } | |
| @@ -275,6 +283,10 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 275 | 283 | return a.d.renameSession(target.session, request.name) | |
| 276 | 284 | case daemonActionRenameTab: | |
| 277 | 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 | 290 | default: | |
| 279 | 291 | return errors.New("daemon: unknown action") | |
| 280 | 292 | } | |
| @@ -397,6 +409,16 @@ func (e controlExec) StackPane() error { | |||
| 397 | 409 | func (e controlExec) ToggleStack() error { | |
| 398 | 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 | 422 | func (e controlExec) focus(direction layout.Direction) error { | |
| 401 | 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 | 43 | type actionRunnerSpy struct { | |
| 44 | 44 | requests []daemonActionRequest | |
| 45 | + | err error | |
| 45 | 46 | } | |
| 46 | 47 | ||
| 47 | 48 | func (s *actionRunnerSpy) Run(request daemonActionRequest) error { | |
| 48 | 49 | s.requests = append(s.requests, request) | |
| 49 | - | return nil | |
| 50 | + | return s.err | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | 53 | func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) { | |
| @@ -240,7 +241,131 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 240 | 241 | active.mu.Unlock() | |
| 241 | 242 | invoking.mu.Lock() | |
| 242 | 243 | require.Len(t, invoking.panes, 2) | |
| 244 | + | beforeFocus := invoking.tree.Focus | |
| 245 | + | beforeGeneration := invoking.layoutGeneration | |
| 243 | 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 | 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 | 268 | tb.mu.Lock() | |
| 269 | 269 | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |
| 270 | 270 | otherPane := false | |
| 271 | + | layoutChanged := false | |
| 271 | 272 | if ev.Type == mouse.Press { | |
| 272 | 273 | // A new press is allowed to change focus, but never the document owned | |
| 273 | 274 | // 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 | 276 | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |
| 276 | 277 | otherPane = true | |
| 277 | 278 | if tb.tree != nil { | |
| 278 | - | focusPlacementLocked(tb, hit.pane.id) | |
| 279 | - | d.applyLayoutLocked(tb) | |
| 279 | + | layoutChanged = focusPlacementLocked(tb, hit.pane.id) | |
| 280 | 280 | } | |
| 281 | 281 | } | |
| 282 | 282 | } | |
| 283 | 283 | tb.mu.Unlock() | |
| 284 | + | if layoutChanged { | |
| 285 | + | d.applyTabLayout(sess, tb) | |
| 286 | + | } | |
| 284 | 287 | if otherPane { | |
| 285 | 288 | d.exitCopyMode(ac) | |
| 286 | 289 | return | |
M internal/usecase/daemon/daemon.go +14 -10
| @@ -105,16 +105,20 @@ type Daemon struct { | |||
| 105 | 105 | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | |
| 106 | 106 | // snapshot is captured and before its mapped position is applied. | |
| 107 | 107 | beforeCopyMouseMap func() | |
| 108 | - | ptys ports.PTYFactory | |
| 109 | - | clock ports.Clock | |
| 110 | - | log *slog.Logger | |
| 111 | - | runtimeObserver ports.RuntimeObserver | |
| 112 | - | baseEnv []string | |
| 113 | - | shell string | |
| 114 | - | shellArgs []string | |
| 115 | - | shellOverride bool | |
| 116 | - | persist *persist.Persister | |
| 117 | - | persistEnabled bool | |
| 108 | + | // beforeSessionResizePublication runs after final external PTY validation | |
| 109 | + | // and before coordinator epoch admission. It is a deterministic regression | |
| 110 | + | // seam for stale resize publication. | |
| 111 | + | beforeSessionResizePublication func() | |
| 112 | + | ptys ports.PTYFactory | |
| 113 | + | clock ports.Clock | |
| 114 | + | log *slog.Logger | |
| 115 | + | runtimeObserver ports.RuntimeObserver | |
| 116 | + | baseEnv []string | |
| 117 | + | shell string | |
| 118 | + | shellArgs []string | |
| 119 | + | shellOverride bool | |
| 120 | + | persist *persist.Persister | |
| 121 | + | persistEnabled bool | |
| 118 | 122 | // snapshotRepository is the sole checkpoint contract. legacySnapshots is | |
| 119 | 123 | // read-only migration input and is never used for new writes. | |
| 120 | 124 | 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 | 20 | if p == nil || !geometry.valid() { | |
| 21 | 21 | return false | |
| 22 | 22 | } | |
| 23 | - | plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}} | |
| 24 | - | d.applyResize(&plan) | |
| 23 | + | plan := preparedTabLayout{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}} | |
| 24 | + | d.applyPreparedTabMembers(&plan) | |
| 25 | 25 | member := plan.members[0] | |
| 26 | 26 | if !member.ok { | |
| 27 | 27 | return false | |
| @@ -31,6 +31,9 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr | |||
| 31 | 31 | p.popupGeometry = geometry | |
| 32 | 32 | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |
| 33 | 33 | p.mu.Unlock() | |
| 34 | + | p.resizeMu.Lock() | |
| 35 | + | d.replayResizePending(member.session, member.tab, p, false, member.rect) | |
| 36 | + | p.resizeMu.Unlock() | |
| 34 | 37 | 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 | 688 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |
| 640 | 689 | t.Run("failure preserves state", func(t *testing.T) { | |
| 641 | 690 | pty := &resizePTY{err: errors.New("nope")} | |
| @@ -673,6 +722,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 673 | 722 | require.Equal(t, second, p.popupGeometry) | |
| 674 | 723 | require.Equal(t, 1, pty.maxConcurrentCalls()) | |
| 675 | 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 | 36 | frameEvent := ev | |
| 37 | 37 | ac.initOverlays() | |
| 38 | 38 | 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 | 40 | return | |
| 41 | 41 | } | |
| 42 | 42 | sess := ac.currentSession() | |
| @@ -89,9 +89,11 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 89 | 89 | return | |
| 90 | 90 | } | |
| 91 | 91 | oldFocus := focusedID | |
| 92 | - | focusPlacementLocked(tb, pl.ID) | |
| 93 | - | d.applyLayoutLocked(tb) | |
| 92 | + | layoutChanged := focusPlacementLocked(tb, pl.ID) | |
| 94 | 93 | tb.mu.Unlock() | |
| 94 | + | if layoutChanged { | |
| 95 | + | d.applyTabLayout(sess, tb) | |
| 96 | + | } | |
| 95 | 97 | // A title bar never routes to terminal content. Clear any pre-existing | |
| 96 | 98 | // left-button candidate before handling the focus result, including when | |
| 97 | 99 | // this press leaves the same pane focused. | |
| @@ -108,13 +110,16 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 108 | 110 | hoveredFocused := true | |
| 109 | 111 | if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) { | |
| 110 | 112 | oldFocus := focusedID | |
| 113 | + | layoutChanged := false | |
| 111 | 114 | if isMouseFocusPress(ev) { | |
| 112 | - | focusPlacementLocked(tb, pl.ID) | |
| 113 | - | d.applyLayoutLocked(tb) | |
| 115 | + | layoutChanged = focusPlacementLocked(tb, pl.ID) | |
| 114 | 116 | } | |
| 115 | 117 | p = tb.panes[pl.ID] | |
| 116 | 118 | hoveredFocused = pl.ID == oldFocus | |
| 117 | 119 | tb.mu.Unlock() | |
| 120 | + | if layoutChanged { | |
| 121 | + | d.applyTabLayout(sess, tb) | |
| 122 | + | } | |
| 118 | 123 | if p == nil { | |
| 119 | 124 | invalidateRejectedLeftPointer(rt, ev) | |
| 120 | 125 | return | |
| @@ -261,8 +266,9 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { | |||
| 261 | 266 | } | |
| 262 | 267 | ||
| 263 | 268 | type daemonKeyHandler struct { | |
| 264 | - | d *Daemon | |
| 265 | - | ac *attachedClient | |
| 269 | + | d *Daemon | |
| 270 | + | ac *attachedClient | |
| 271 | + | actions daemonActionRunner | |
| 266 | 272 | } | |
| 267 | 273 | ||
| 268 | 274 | func (h daemonKeyHandler) Forward(data []byte) { | |
| @@ -285,6 +291,20 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 285 | 291 | if sess == nil { | |
| 286 | 292 | 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 | 308 | switch action { | |
| 289 | 309 | case keys.ActionOpenPalette: | |
| 290 | 310 | h.d.enterPalette(sess, h.ac) | |
| @@ -313,6 +333,16 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 313 | 333 | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { | |
| 314 | 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 | 346 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 317 | 347 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 318 | 348 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
M internal/usecase/daemon/input_test.go +181 -0
| @@ -26,6 +26,187 @@ import ( | |||
| 26 | 26 | ||
| 27 | 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 | 210 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 30 | 211 | // 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 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| @@ -66,10 +67,41 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||
| 66 | 67 | ||
| 67 | 68 | func layoutFingerprint(root *layout.Node) string { | |
| 68 | 69 | var b strings.Builder | |
| 70 | + | b.Grow(layoutFingerprintLength(root)) | |
| 69 | 71 | writeLayoutFingerprint(&b, root) | |
| 70 | 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 | 105 | func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |
| 74 | 106 | if n == nil { | |
| 75 | 107 | b.WriteByte('0') | |
| @@ -77,6 +109,8 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |||
| 77 | 109 | } | |
| 78 | 110 | b.WriteByte(byte('0' + n.Kind)) | |
| 79 | 111 | b.WriteByte(byte('0' + n.Dir)) | |
| 112 | + | writeWeightFingerprint(b, n.Weight) | |
| 113 | + | b.WriteByte('|') | |
| 80 | 114 | writePaneIDFingerprint(b, n.Leaf) | |
| 81 | 115 | b.WriteByte('|') | |
| 82 | 116 | writePaneIDFingerprint(b, n.Expanded) | |
| @@ -88,6 +122,22 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |||
| 88 | 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 | 141 | func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) { | |
| 92 | 142 | s := string(id) | |
| 93 | 143 | b.WriteString(strconv.Itoa(len(s))) | |
| @@ -99,12 +149,19 @@ func pointInRect(col, row int, r domain.Rect) bool { | |||
| 99 | 149 | 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 | 153 | if tb == nil || tb.tree == nil { | |
| 104 | - | return | |
| 154 | + | return false | |
| 105 | 155 | } | |
| 156 | + | oldFocus := tb.tree.Focus | |
| 157 | + | oldLayout := layoutFingerprint(tb.tree.Root) | |
| 106 | 158 | tb.tree.Focus = id | |
| 107 | 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 | 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 | 228 | sess.mu.Unlock() | |
| 229 | 229 | 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 | 233 | candidate.target.mu.Unlock() | |
| 233 | 234 | candidate.source.mu.Unlock() | |
| 234 | 235 | sess.mu.Unlock() | |
| 235 | 236 | return false | |
| 236 | 237 | } | |
| 237 | - | d.applyLayoutLocked(candidate.target) | |
| 238 | + | candidate.target.tree = targetTree | |
| 239 | + | candidate.target.bumpLayoutGenerationLocked() | |
| 238 | 240 | sess.active = targetIndex | |
| 239 | 241 | candidate.target.mu.Unlock() | |
| 240 | 242 | candidate.source.mu.Unlock() | |
| 241 | 243 | sess.mu.Unlock() | |
| 242 | - | markSnapshotDirty(sess) | |
| 244 | + | d.applyTabLayout(sess, candidate.target) | |
| 243 | 245 | return true | |
| 244 | 246 | } | |
M internal/usecase/daemon/nil_pty_test.go +5 -1
| @@ -23,8 +23,12 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 23 | 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 24 | 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 | 30 | require.NotPanics(t, func() { | |
| 27 | - | d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) | |
| 31 | + | d.applyTabLayout(sess, first) | |
| 28 | 32 | }) | |
| 29 | 33 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | |
| 30 | 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 | 13 | cancel func() | |
| 14 | 14 | up func() | |
| 15 | 15 | down func() | |
| 16 | + | left func() | |
| 17 | + | right func() | |
| 16 | 18 | } | |
| 17 | 19 | ||
| 18 | 20 | func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) { | |
| @@ -92,6 +94,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete | |||
| 92 | 94 | call(ev.up) | |
| 93 | 95 | case 'B': | |
| 94 | 96 | call(ev.down) | |
| 97 | + | case 'C': | |
| 98 | + | call(ev.right) | |
| 99 | + | case 'D': | |
| 100 | + | call(ev.left) | |
| 95 | 101 | } | |
| 96 | 102 | return 3, false | |
| 97 | 103 | case '[': | |
| @@ -105,6 +111,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete | |||
| 105 | 111 | call(ev.up) | |
| 106 | 112 | case 'B': | |
| 107 | 113 | call(ev.down) | |
| 114 | + | case 'C': | |
| 115 | + | call(ev.right) | |
| 116 | + | case 'D': | |
| 117 | + | call(ev.left) | |
| 108 | 118 | } | |
| 109 | 119 | } | |
| 110 | 120 | return i + 1, false | |
M internal/usecase/daemon/overlay_runtime.go +24 -1
| @@ -37,6 +37,11 @@ type overlayRuntime struct { | |||
| 37 | 37 | promptSubmit func(string) error | |
| 38 | 38 | promptPending []byte | |
| 39 | 39 | ||
| 40 | + | resizeMu sync.Mutex | |
| 41 | + | resizeActive bool | |
| 42 | + | resizePending []byte | |
| 43 | + | resizeESC pendingByteTimer | |
| 44 | + | ||
| 40 | 45 | copyMu sync.Mutex | |
| 41 | 46 | copyMode *scopy.Mode | |
| 42 | 47 | copyCandidate *scopy.Mode | |
| @@ -98,7 +103,7 @@ func (rt *overlayRuntime) Active() bool { | |||
| 98 | 103 | if rt == nil || rt.ac == nil { | |
| 99 | 104 | 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 | 109 | func (rt *overlayRuntime) promptActive() bool { | |
| @@ -137,6 +142,15 @@ func (rt *overlayRuntime) noticesActive() bool { | |||
| 137 | 142 | 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 | 154 | func (rt *overlayRuntime) copyActive() bool { | |
| 141 | 155 | if rt == nil { | |
| 142 | 156 | return false | |
| @@ -235,6 +249,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |||
| 235 | 249 | d.handleNoticesInput(ac, data) | |
| 236 | 250 | return true | |
| 237 | 251 | } | |
| 252 | + | if rt.resizeModeActive() { | |
| 253 | + | d.handleResizeInput(ac, data) | |
| 254 | + | return true | |
| 255 | + | } | |
| 238 | 256 | if rt.copyActive() { | |
| 239 | 257 | d.handleCopyInput(ac, data) | |
| 240 | 258 | return true | |
| @@ -250,6 +268,7 @@ type overlayRenderSnapshot struct { | |||
| 250 | 268 | copyPane *pane | |
| 251 | 269 | copySearchModel *visualsearch.Model | |
| 252 | 270 | statusFeedback string | |
| 271 | + | resizeActive bool | |
| 253 | 272 | ||
| 254 | 273 | pickerActive bool | |
| 255 | 274 | pickerModel *picker.Model | |
| @@ -316,6 +335,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 316 | 335 | } | |
| 317 | 336 | rt.copyMu.Unlock() | |
| 318 | 337 | ||
| 338 | + | rt.resizeMu.Lock() | |
| 339 | + | snap.resizeActive = rt.resizeActive | |
| 340 | + | rt.resizeMu.Unlock() | |
| 341 | + | ||
| 319 | 342 | rt.pickerMu.Lock() | |
| 320 | 343 | snap.pickerActive = rt.picker != nil | |
| 321 | 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 | 418 | func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) } | |
| 419 | 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 | 433 | func (e paletteExec) NextTab() error { | |
| 422 | 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 | 617 | for i, cmd := range commands { | |
| 618 | 618 | 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 | 623 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 624 | 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 | 627 | var wg sync.WaitGroup | |
| 628 | 628 | for range 50 { | |
M internal/usecase/daemon/pane.go +2 -1
| @@ -18,7 +18,7 @@ type pane struct { | |||
| 18 | 18 | id layout.PaneID | |
| 19 | 19 | stableID string | |
| 20 | 20 | pty ports.PTY | |
| 21 | - | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title | |
| 21 | + | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, and title | |
| 22 | 22 | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |
| 23 | 23 | screen *vt.Screen | |
| 24 | 24 | history *vt.History | |
| @@ -28,6 +28,7 @@ type pane struct { | |||
| 28 | 28 | // draining into resizePending so output is replayed against the target (or | |
| 29 | 29 | // retained old) screen only after apply resolves. | |
| 30 | 30 | resizeApplying bool | |
| 31 | + | resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan. | |
| 31 | 32 | resizePending []byte | |
| 32 | 33 | ptyResponses []byte | |
| 33 | 34 | ptyClipboards []string | |
M internal/usecase/daemon/pane_actions.go +25 -73
| @@ -56,7 +56,8 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 56 | 56 | } | |
| 57 | 57 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |
| 58 | 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 | 61 | tb.mu.Unlock() | |
| 61 | 62 | if errors.Is(err, layout.ErrTooSmall) { | |
| 62 | 63 | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err) | |
| @@ -64,33 +65,24 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 64 | 65 | return err | |
| 65 | 66 | } | |
| 66 | 67 | tb.nextPaneID++ | |
| 67 | - | placements, ok := layout.Solve(tb.tree.Root, area) | |
| 68 | + | placements, ok := layout.Solve(candidate.Root, area) | |
| 68 | 69 | if !ok { | |
| 69 | - | _ = tb.tree.Close(newID) | |
| 70 | - | tb.tree.Focus = oldFocus | |
| 71 | 70 | tb.mu.Unlock() | |
| 72 | 71 | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall) | |
| 73 | 72 | } | |
| 74 | 73 | newRect := placementContent(placements, newID) | |
| 75 | 74 | tabStableID := tb.stableID | |
| 75 | + | generation := tb.layoutGeneration | |
| 76 | 76 | tb.mu.Unlock() | |
| 77 | 77 | ||
| 78 | 78 | paneStableID, err := newStableID("p") | |
| 79 | 79 | if err != nil { | |
| 80 | - | tb.mu.Lock() | |
| 81 | - | _ = tb.tree.Close(newID) | |
| 82 | - | tb.tree.Focus = oldFocus | |
| 83 | - | tb.mu.Unlock() | |
| 84 | 80 | return fmt.Errorf("daemon: generating pane identity: %w", err) | |
| 85 | 81 | } | |
| 86 | 82 | command, args := d.ptyCommand(env) | |
| 87 | 83 | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | |
| 88 | 84 | if err != nil { | |
| 89 | 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 | 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 | 92 | p.rect = newRect | |
| 101 | 93 | ||
| 102 | 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 | 96 | tb.mu.Unlock() | |
| 105 | 97 | cancel() | |
| 106 | 98 | _ = pty.Close() | |
| 107 | 99 | return layout.ErrNotFound | |
| 108 | 100 | } | |
| 101 | + | tb.tree = candidate | |
| 109 | 102 | tb.panes[newID] = p | |
| 110 | - | tb.tree.Focus = newID | |
| 111 | - | d.applyLayoutLocked(tb) | |
| 103 | + | tb.bumpLayoutGenerationLocked() | |
| 112 | 104 | tb.mu.Unlock() | |
| 113 | 105 | ||
| 106 | + | d.applyTabLayout(sess, tb) | |
| 114 | 107 | d.startPaneGoroutines(sess, tb, p) | |
| 115 | - | markSnapshotDirty(sess) | |
| 116 | 108 | 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 | 111 | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | |
| 163 | 112 | for _, pl := range placements { | |
| 164 | 113 | if pl.ID == id { | |
| @@ -210,13 +159,15 @@ func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error { | |||
| 210 | 159 | tb.mu.Unlock() | |
| 211 | 160 | 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 | 164 | if err == nil { | |
| 215 | - | d.applyLayoutLocked(tb) | |
| 165 | + | tb.tree = candidate | |
| 166 | + | tb.bumpLayoutGenerationLocked() | |
| 216 | 167 | } | |
| 217 | 168 | tb.mu.Unlock() | |
| 218 | 169 | if err == nil { | |
| 219 | - | markSnapshotDirty(sess) | |
| 170 | + | d.applyTabLayout(sess, tb) | |
| 220 | 171 | } | |
| 221 | 172 | return err | |
| 222 | 173 | } | |
| @@ -276,8 +227,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 276 | 227 | return err | |
| 277 | 228 | } | |
| 278 | 229 | delete(tb.panes, id) | |
| 279 | - | d.applyLayoutLocked(tb) | |
| 230 | + | tb.bumpLayoutGenerationLocked() | |
| 280 | 231 | tb.mu.Unlock() | |
| 232 | + | d.applyTabLayout(sess, tb) | |
| 281 | 233 | ||
| 282 | 234 | if ac != nil { | |
| 283 | 235 | ac.overlays.clearCopyModeForPane(p) | |
| @@ -294,7 +246,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 294 | 246 | _ = p.pty.Close() | |
| 295 | 247 | } | |
| 296 | 248 | d.log.Info("pane closed", "session", sess.name, "pane", id) | |
| 297 | - | markSnapshotDirty(sess) | |
| 298 | 249 | if repaint { | |
| 299 | 250 | if ac != nil { | |
| 300 | 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 | 326 | tb.mu.Unlock() | |
| 376 | 327 | return domain.Rect{}, layout.ErrNotFound | |
| 377 | 328 | } | |
| 378 | - | oldFocus := target.id | |
| 379 | - | tb.tree.Focus = oldFocus | |
| 329 | + | candidate := tb.tree.Clone() | |
| 330 | + | candidate.Focus = target.id | |
| 380 | 331 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 381 | - | span, _ := tb.tree.FocusSpan(area) | |
| 382 | - | err := tb.tree.FocusDir(dir, area) | |
| 383 | - | newFocus := tb.tree.Focus | |
| 384 | - | if err == nil { | |
| 385 | - | d.applyLayoutLocked(tb) | |
| 332 | + | span, _ := candidate.FocusSpan(area) | |
| 333 | + | err := candidate.FocusDir(dir, area) | |
| 334 | + | committed := err == nil && candidate.Focus != tb.tree.Focus | |
| 335 | + | if committed { | |
| 336 | + | tb.tree = candidate | |
| 337 | + | tb.bumpLayoutGenerationLocked() | |
| 386 | 338 | } | |
| 387 | 339 | tb.mu.Unlock() | |
| 388 | - | if err == nil && newFocus != oldFocus { | |
| 389 | - | markSnapshotDirty(sess) | |
| 340 | + | if committed { | |
| 341 | + | d.applyTabLayout(sess, tb) | |
| 390 | 342 | } | |
| 391 | 343 | if errors.Is(err, layout.ErrNoPane) { | |
| 392 | 344 | return span, errNoNeighbor | |
M internal/usecase/daemon/pane_actions_test.go +433 -3
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | 7 | "log/slog" | |
| 8 | + | "sync" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| @@ -19,6 +20,437 @@ import ( | |||
| 19 | 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 | 454 | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |
| 23 | 455 | tests := []struct { | |
| 24 | 456 | name string | |
| @@ -123,9 +555,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||
| 123 | 555 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 124 | 556 | newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 125 | 557 | ||
| 126 | - | tb.mu.Lock() | |
| 127 | - | d.applyLayoutLocked(tb) | |
| 128 | - | tb.mu.Unlock() | |
| 558 | + | d.applyTabLayout(sess, tb) | |
| 129 | 559 | ||
| 130 | 560 | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | |
| 131 | 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 | 1110 | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { | |
| 1111 | 1111 | f.t.Helper() | |
| 1112 | 1112 | tb.mu.Lock() | |
| 1113 | - | defer tb.mu.Unlock() | |
| 1114 | 1113 | tb.size = size | |
| 1115 | 1114 | left := tb.focusedPane() | |
| 1116 | 1115 | for paneIndex := 1; paneIndex < paneCount; paneIndex++ { | |
| @@ -1121,8 +1120,13 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||
| 1121 | 1120 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | |
| 1122 | 1121 | require.True(f.t, ok, "performance fixture layout must be solvable") | |
| 1123 | 1122 | require.Len(f.t, placements, paneCount) | |
| 1124 | - | f.d.applyLayoutLocked(tb) | |
| 1125 | - | for _, p := range tb.panes { | |
| 1123 | + | tb.bumpLayoutGenerationLocked() | |
| 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 | 1130 | p.mu.Lock() | |
| 1127 | 1131 | width := p.screen.Frame.Width | |
| 1128 | 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 | 1252 | func (f *performanceFixture) retryLatest() { | |
| 1249 | 1253 | 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 | 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 | 1266 | failuresAfter, callsAfter := f.pty.metrics() | |
| 1254 | 1267 | f.ptyFailures += failuresAfter - failuresBefore | |
| 1255 | 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 | 298 | d.refreshSessionFocusedTitles(sess) | |
| 299 | 299 | // Contextual ranks are completely captured under paletteMu. Compose them | |
| 300 | 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 | 306 | applied := ac.getAppliedTheme() | |
| 303 | 307 | bars.theme = applied.Raw | |
| 304 | 308 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| @@ -327,7 +331,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 327 | 331 | capturedOverlays := capturedOverlayRenderState{ | |
| 328 | 332 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | |
| 329 | 333 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |
| 330 | - | statusFeedback: overlays.statusFeedback, | |
| 334 | + | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | |
| 331 | 335 | } | |
| 332 | 336 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 333 | 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 | 87 | type capturedOverlayRenderState struct { | |
| 88 | 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |
| 89 | - | noticesOverlayActive bool | |
| 89 | + | noticesOverlayActive, resizeActive bool | |
| 90 | 90 | copyMode *scopy.Mode | |
| 91 | 91 | copyPaneID layout.PaneID | |
| 92 | 92 | statusFeedback string | |
M internal/usecase/daemon/render_pipeline_cache_test.go +39 -0
| @@ -125,6 +125,45 @@ func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *tes | |||
| 125 | 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 | 167 | func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) { | |
| 129 | 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 | 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 | 372 | tb.mu.Lock() | |
| 373 | 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 | 374 | tb.panes[closed.id] = closed | |
| 375 | - | d.applyLayoutLocked(tb) | |
| 375 | + | tb.bumpLayoutGenerationLocked() | |
| 376 | 376 | tb.mu.Unlock() | |
| 377 | + | d.applyTabLayout(sess, tb) | |
| 377 | 378 | survivor.screen.Write([]byte("survivor")) | |
| 378 | 379 | closed.screen.Write([]byte("closed")) | |
| 379 | 380 | d.paint(sess, ac, true, nil) | |
M internal/usecase/daemon/session.go +19 -0
| @@ -105,6 +105,10 @@ type session struct { | |||
| 105 | 105 | syncGen atomic.Uint64 | |
| 106 | 106 | // coordinator fans in this session's producer render invalidations. | |
| 107 | 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 | 112 | // clipFiles records clipboard-image-transfer temp file paths (see | |
| 109 | 113 | // clipboard.go) written for this session, removed best-effort in | |
| 110 | 114 | // killSession. | |
| @@ -122,6 +126,17 @@ type session struct { | |||
| 122 | 126 | type tab struct { | |
| 123 | 127 | 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 | 140 | stableID string | |
| 126 | 141 | name string | |
| 127 | 142 | 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 | 457 | func (tb *tab) focusedPane() *pane { | |
| 439 | 458 | if tb == nil || tb.tree == nil || tb.panes == nil { | |
| 440 | 459 | return nil | |
M internal/usecase/daemon/transactional_resize.go +599 -153
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain" | |
| 5 | 7 | "github.com/bnema/vev/internal/ports" | |
| 6 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -9,116 +11,303 @@ import ( | |||
| 9 | 11 | // resizeMember is an unpublished layout decision. Prepare only reads guarded | |
| 10 | 12 | // state; apply performs the external PTY call; commit is the sole publisher. | |
| 11 | 13 | type resizeMember struct { | |
| 12 | - | session *session | |
| 13 | - | tab *tab | |
| 14 | - | pane *pane | |
| 15 | - | rect domain.Rect | |
| 16 | - | floating floatingGeometry | |
| 17 | - | isFloating bool | |
| 18 | - | retry bool | |
| 19 | - | ok bool | |
| 20 | - | screenResized bool | |
| 14 | + | session *session | |
| 15 | + | tab *tab | |
| 16 | + | pane *pane | |
| 17 | + | rect domain.Rect | |
| 18 | + | floating floatingGeometry | |
| 19 | + | isFloating bool | |
| 20 | + | // floatingGeneration identifies the exact popup slot accepted by a failed | |
| 21 | + | // resize. Floating panes are intentionally outside tb.panes, so retries | |
| 22 | + | // 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 { | |
| 24 | - | size domain.Size | |
| 25 | - | tabs []*tab | |
| 26 | - | members []resizeMember | |
| 30 | + | type preparedTabLayout struct { | |
| 31 | + | tab *tab | |
| 32 | + | generation uint64 | |
| 33 | + | 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 { | |
| 30 | - | plan := resizePlan{size: size} | |
| 31 | - | sess.mu.Lock() | |
| 32 | - | plan.tabs = append(plan.tabs, sess.tabs...) | |
| 33 | - | active := sess.active | |
| 34 | - | sess.mu.Unlock() | |
| 35 | - | for i, tb := range plan.tabs { | |
| 36 | - | tb.mu.Lock() | |
| 37 | - | if tb.tree != nil && tb.tree.Root != nil { | |
| 38 | - | ts := tabSize(size) | |
| 39 | - | area := domain.Rect{Width: ts.Cols, Height: ts.Rows} | |
| 40 | - | if placements, ok := layout.Solve(tb.tree.Root, area); ok { | |
| 41 | - | for _, pl := range placements { | |
| 42 | - | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 43 | - | if p := tb.panes[pl.ID]; p != nil { | |
| 44 | - | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: pl.Content}) | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | } | |
| 48 | - | } else if tb.tree.Root.Kind == layout.Leaf { | |
| 49 | - | // A single pane remains a valid PTY target even when a tiny client | |
| 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 | - | } | |
| 38 | + | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | |
| 39 | + | return prepareTabLayoutForSizeLocked(sess, tb, tb.size) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) preparedTabLayout { | |
| 43 | + | plan := preparedTabLayout{tab: tb, generation: tb.layoutGeneration, size: size, previousSize: tb.size} | |
| 44 | + | if tb.tree == nil || tb.tree.Root == nil || !size.Valid() { | |
| 45 | + | return plan | |
| 46 | + | } | |
| 47 | + | area := domain.Rect{Width: size.Cols, Height: size.Rows} | |
| 48 | + | placements, ok := layout.Solve(tb.tree.Root, area) | |
| 49 | + | if !ok && tb.tree.Root.Kind == layout.Leaf { | |
| 50 | + | placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}} | |
| 51 | + | ok = true | |
| 52 | + | } | |
| 53 | + | if !ok { | |
| 54 | + | return plan | |
| 55 | + | } | |
| 56 | + | for _, placement := range placements { | |
| 57 | + | if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 { | |
| 58 | + | continue | |
| 57 | 59 | } | |
| 58 | - | // Hidden retained popups enter this same primitive when shown; only a | |
| 59 | - | // visible popup participates in an unrelated client resize. | |
| 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 | - | } | |
| 60 | + | if p := tb.panes[placement.ID]; p != nil { | |
| 61 | + | plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, rect: placement.Content}) | |
| 65 | 62 | } | |
| 66 | - | tb.mu.Unlock() | |
| 67 | 63 | } | |
| 68 | 64 | return plan | |
| 69 | 65 | } | |
| 70 | 66 | ||
| 71 | - | // applyResize deliberately holds only a pane's resizeMu around its PTY call; | |
| 72 | - | // no daemon, session, tab, or pane lock crosses the external boundary. | |
| 73 | - | func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | |
| 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 | - | } | |
| 67 | + | // applyPreparedTabMembers performs only the external PTY phase. A successful | |
| 68 | + | // resize leaves the parser gate closed until the complete plan is validated. | |
| 69 | + | func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |
| 87 | 70 | for i := range plan.members { | |
| 88 | - | if len(current) != 0 && !current[0]() { | |
| 89 | - | reportFailure() | |
| 90 | - | return false | |
| 91 | - | } | |
| 92 | - | m := &plan.members[i] | |
| 93 | - | m.pane.resizeMu.Lock() | |
| 94 | - | m.pane.mu.Lock() | |
| 95 | - | old, pty := m.pane.rect, m.pane.pty | |
| 96 | - | screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height} | |
| 97 | - | needsApply := m.retry || old.Width != m.rect.Width || old.Height != m.rect.Height || screenSize != rectSize(m.rect) | |
| 71 | + | member := &plan.members[i] | |
| 72 | + | p := member.pane | |
| 73 | + | p.resizeMu.Lock() | |
| 74 | + | p.mu.Lock() | |
| 75 | + | old := p.rect | |
| 76 | + | pty := p.pty | |
| 77 | + | // A rejected earlier attempt deliberately leaves its parser gate open. | |
| 78 | + | // Reapply even if the committed rectangle already matches: the PTY may | |
| 79 | + | // still be at that rejected attempt's external size. An accepted failed | |
| 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 | 82 | if needsApply && pty != nil { | |
| 99 | - | // Publish this gate before the external call. ptyReader keeps draining | |
| 100 | - | // but does not parse bytes at the stale width while Resize is in flight. | |
| 101 | - | m.pane.resizeApplying = true | |
| 83 | + | p.resizeApplying = true | |
| 102 | 84 | } | |
| 103 | - | m.pane.mu.Unlock() | |
| 85 | + | p.mu.Unlock() | |
| 104 | 86 | if !needsApply || pty == nil { | |
| 105 | - | m.ok = true | |
| 106 | - | } else if err := pty.Resize(rectSize(m.rect)); err != nil { | |
| 87 | + | member.ok = true | |
| 88 | + | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | |
| 107 | 89 | d.log.Warn("pty resize failed", "err", err) | |
| 108 | - | failed++ | |
| 109 | - | lastErr = err | |
| 110 | - | d.replayResizePending(m.session, m.tab, m.pane, false, m.rect) | |
| 90 | + | p.mu.Lock() | |
| 91 | + | p.resizeRetry = true | |
| 92 | + | p.mu.Unlock() | |
| 93 | + | d.replayResizePending(member.session, member.tab, p, false, member.rect) | |
| 94 | + | member.retry = true | |
| 95 | + | member.err = err | |
| 111 | 96 | } else { | |
| 112 | - | m.ok = true | |
| 113 | - | d.replayResizePending(m.session, m.tab, m.pane, true, m.rect) | |
| 114 | - | m.screenResized = true | |
| 97 | + | member.ok = true | |
| 98 | + | member.screenResized = true // records that this member owns an open gate | |
| 99 | + | } | |
| 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 | 247 | 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 | 311 | // replayResizePending completes a pane's apply epoch. It keeps the gate set | |
| 123 | 312 | // while each buffered batch goes through the normal VT path, so reads arriving | |
| 124 | 313 | // 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) { | |
| 154 | - | // Publish tab layout and every rectangle together only after the coordinator | |
| 155 | - | // has rejected obsolete work. A later request may have applied already, but | |
| 156 | - | // it can never publish through this epoch. | |
| 157 | - | for _, tb := range plan.tabs { | |
| 158 | - | tb.mu.Lock() | |
| 159 | - | tb.size = tabSize(plan.size) | |
| 342 | + | // applyVisibleFloatingLayout retains floating panes' existing independent | |
| 343 | + | // lifecycle while routing their external resize through the same lock-free gate | |
| 344 | + | // used for retryable PTY work. Floating state is not part of a tiled layout | |
| 345 | + | // generation, so its slot generation and exact pane identity validate its | |
| 346 | + | // publication instead. | |
| 347 | + | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { | |
| 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 | 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 { | |
| 163 | - | // Layout geometry always publishes together. Failed members deliberately | |
| 164 | - | // retain their old VT screen; capture clips that screen to this new rect | |
| 165 | - | // and pads the uncovered cells with normal blanks. | |
| 166 | - | m.pane.mu.Lock() | |
| 167 | - | m.pane.rect = m.rect | |
| 168 | - | if m.isFloating { | |
| 169 | - | m.pane.popupGeometry = m.floating | |
| 388 | + | if currentSlot { | |
| 389 | + | p.mu.Lock() | |
| 390 | + | p.rect = geometry.Inner | |
| 391 | + | p.popupGeometry = geometry | |
| 392 | + | if plan.members[0].ok { | |
| 393 | + | p.resizeRetry = false | |
| 394 | + | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |
| 170 | 395 | } | |
| 171 | - | if m.ok && !m.screenResized { | |
| 172 | - | m.pane.screen.Resize(m.rect.Width, m.rect.Height) | |
| 396 | + | p.mu.Unlock() | |
| 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 { | |
| 177 | - | ac.sendMu.Lock() | |
| 178 | - | ac.size = plan.size | |
| 179 | - | ac.sendMu.Unlock() | |
| 416 | + | if plan.members[0].ok { | |
| 417 | + | d.finishPreparedTabMembers(&plan, true) | |
| 418 | + | return nil, true | |
| 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 | 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 | 558 | return false | |
| 189 | 559 | } | |
| 190 | 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 | 563 | return false | |
| 193 | 564 | } | |
| 194 | 565 | d.exitCopyMode(ac) | |
| 195 | - | plan := d.prepareResize(sess, snap.size) | |
| 196 | - | if !rc.resizeCurrentForLease(epoch, ac, lease, false) { | |
| 197 | - | return false | |
| 198 | - | } | |
| 199 | - | if !d.applyResize(&plan, func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }) { | |
| 566 | + | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | |
| 567 | + | return rc.resizeCurrentForLease(epoch, ac, lease, true) | |
| 568 | + | }) | |
| 569 | + | if !ok { | |
| 200 | 570 | return false | |
| 201 | 571 | } | |
| 202 | - | if !rc.resizeCurrentForLease(epoch, ac, lease, true) { | |
| 203 | - | return false | |
| 204 | - | } | |
| 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 | - | } | |
| 572 | + | ac.sendMu.Lock() | |
| 573 | + | ac.size = snap.size | |
| 574 | + | ac.sendMu.Unlock() | |
| 212 | 575 | if len(failed) != 0 { | |
| 213 | 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 | 588 | return true | |
| 226 | 589 | } | |
| 227 | 590 | ||
| 228 | - | // retryResizeMembers retries only members which failed the committed epoch. | |
| 229 | - | // It never republishes geometry: an intervening resize owns that publication. | |
| 591 | + | // retryResizeMembers retries failed committed members through a freshly | |
| 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 | 595 | func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) { | |
| 231 | 596 | rc := sess.renderCoordinator() | |
| 232 | 597 | if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) { | |
| 233 | 598 | return | |
| 234 | 599 | } | |
| 235 | - | plan := resizePlan{members: append([]resizeMember(nil), members...)} | |
| 236 | - | for i := range plan.members { | |
| 237 | - | plan.members[i].retry = true | |
| 238 | - | } | |
| 239 | - | if !d.applyResize(&plan, func() bool { return rc.retryCurrentForLease(epoch, ac, lease) }) || !rc.retryCurrentForLease(epoch, ac, lease) { | |
| 240 | - | return | |
| 600 | + | ||
| 601 | + | // Retain tab order for deterministic external PTY ordering while collapsing | |
| 602 | + | // several failed members from the same tab into one canonical transaction. | |
| 603 | + | tabs := make([]*tab, 0, len(members)) | |
| 604 | + | seen := make(map[*tab]struct{}, len(members)) | |
| 605 | + | 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 | 617 | succeeded := false | |
| 244 | - | for _, member := range plan.members { | |
| 245 | - | if !member.ok { | |
| 246 | - | failed = append(failed, member) | |
| 618 | + | for _, tb := range tabs { | |
| 619 | + | if !rc.retryCurrentForLease(epoch, ac, lease) { | |
| 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 | 637 | continue | |
| 248 | 638 | } | |
| 249 | - | member.pane.mu.Lock() | |
| 250 | - | // The rect is the already committed layout target. A successful apply | |
| 251 | - | // already resized before replaying buffered bytes; PTY-less members still | |
| 252 | - | // need their VT resized here. Force a reset below in either case. | |
| 253 | - | if !member.screenResized { | |
| 254 | - | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | |
| 639 | + | ||
| 640 | + | // applyTabLayoutTransaction captures the current generation, pane | |
| 641 | + | // pointers, and solved rectangles, and validates them again before any | |
| 642 | + | // VT/rectangle publication. It also preserves the resize gate and | |
| 643 | + | // degradation notice behavior for another failed external attempt. | |
| 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 | 707 | if len(failed) != 0 { | |
| 260 | 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 | 737 | if ac == nil { | |
| 290 | 738 | // Headless geometry has no coordinator/transport to coalesce, but keeps | |
| 291 | 739 | // the same prepare/apply/commit ordering. | |
| 292 | - | plan := d.prepareResize(sess, size) | |
| 293 | - | d.applyResize(&plan) | |
| 294 | - | d.commitResize(sess, nil, plan) | |
| 295 | - | return true | |
| 740 | + | _, ok := d.applySessionLayout(sess, size, nil, nil) | |
| 741 | + | return ok | |
| 296 | 742 | } | |
| 297 | 743 | rc := sess.renderCoordinator() | |
| 298 | 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 | 181 | // invoke PTY.Resize after all session/tab/pane locks are released. This is the | |
| 182 | 182 | // deadlock boundary with PTY readers and scripts which synchronously re-enter | |
| 183 | 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 | 203 | func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) { | |
| 185 | 204 | pty := &transactionalResizePTY{} | |
| 186 | 205 | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| @@ -204,105 +223,47 @@ func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) { | |||
| 204 | 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 notice | |
| 208 | - | // per applyResize call, collected inside the resizeMu loop and reported once | |
| 209 | - | // after it returns to lock-free code. The count is the meaningful assertion: | |
| 210 | - | // notify acquires sess.mu/d.mu and must never run while any resizeMu is held. | |
| 211 | - | func TestTransactionalResizeApplyReportsFailureOncePerCall(t *testing.T) { | |
| 212 | - | countResizeFailed := func(d *Daemon) (int, domain.Notification) { | |
| 213 | - | var last domain.Notification | |
| 214 | - | count := 0 | |
| 215 | - | for _, h := range d.notices.history() { | |
| 216 | - | if h.Code == domain.NoticeResizeFailed { | |
| 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 | |
| 226 | + | // S3 acceptance: a failed member never publishes speculative parser/screen or | |
| 227 | + | // rectangle state. Successful members and the client layout commit together; | |
| 228 | + | // composition clips/pads the retained failed screen against that new layout. | |
| 229 | + | func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) { | |
| 230 | + | for _, tc := range []struct { | |
| 231 | + | name string | |
| 232 | + | failureCount int | |
| 233 | + | wantOK bool | |
| 234 | + | wantFailed int | |
| 235 | + | wantFailureNotice int | |
| 248 | 236 | }{ | |
| 249 | - | { | |
| 250 | - | name: "normal completion reports one combined failure", | |
| 251 | - | firstErr: errors.New("first fails"), | |
| 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 | - | }, | |
| 237 | + | {name: "zero failures", wantOK: true}, | |
| 238 | + | {name: "one failure", failureCount: 1, wantOK: true, wantFailed: 1, wantFailureNotice: 1}, | |
| 239 | + | {name: "two failures", failureCount: 2, wantOK: true, wantFailed: 2, wantFailureNotice: 1}, | |
| 275 | 240 | } { | |
| 276 | - | t.Run(tt.name, func(t *testing.T) { | |
| 277 | - | first := &transactionalResizePTY{errs: []error{tt.firstErr}} | |
| 278 | - | second := &transactionalResizePTY{errs: []error{tt.secondErr}} | |
| 279 | - | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | |
| 280 | - | tabs := sess.tabs | |
| 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) | |
| 241 | + | t.Run(tc.name, func(t *testing.T) { | |
| 242 | + | first := &transactionalResizePTY{} | |
| 243 | + | second := &transactionalResizePTY{} | |
| 244 | + | if tc.failureCount >= 1 { | |
| 245 | + | first.errs = []error{errors.New("first failure")} | |
| 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) | |
| 295 | - | require.Equal(t, tt.wantCount, count, "one notice per applyResize call, including stale returns") | |
| 296 | - | if !tt.wantResult { | |
| 297 | - | require.Equal(t, []domain.Size(nil), second.requested(), "stale current check must stop before the next member") | |
| 254 | + | require.Equal(t, tc.wantOK, ok) | |
| 255 | + | require.Len(t, failed, tc.wantFailed) | |
| 256 | + | count := 0 | |
| 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 | 267 | func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) { | |
| 307 | 268 | ok := &transactionalResizePTY{} | |
| 308 | 269 | failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}} | |
| @@ -423,6 +384,140 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) { | |||
| 423 | 384 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | |
| 424 | 385 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | |
| 425 | 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 | 521 | func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) { | |
| 427 | 522 | clock := newCoordinatorMockClock(t, 8) | |
| 428 | 523 | pty := &transactionalResizePTY{} | |
M internal/usecase/keys/keyspec.go +5 -0
| @@ -65,6 +65,11 @@ var actionNames = map[Action]string{ | |||
| 65 | 65 | ActionSwitchTab7: "switch-tab-7", | |
| 66 | 66 | ActionSwitchTab8: "switch-tab-8", | |
| 67 | 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 | 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 | 79 | {ActionSwitchTab7, "switch-tab-7"}, | |
| 80 | 80 | {ActionSwitchTab8, "switch-tab-8"}, | |
| 81 | 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 | 89 | for _, tc := range cases { | |
| @@ -114,6 +119,36 @@ func TestBuildBindingsAppliesOverrides(t *testing.T) { | |||
| 114 | 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 | 152 | func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) { | |
| 118 | 153 | bindings, warnings := BuildBindings(map[string]string{ | |
| 119 | 154 | "missing": "alt+m", | |
M internal/usecase/keys/router.go +5 -0
| @@ -38,6 +38,11 @@ const ( | |||
| 38 | 38 | ActionFocusPaneUp | |
| 39 | 39 | ActionFocusPaneDown | |
| 40 | 40 | ActionToggleFloatingPane | |
| 41 | + | ActionGrowPaneWidth | |
| 42 | + | ActionShrinkPaneWidth | |
| 43 | + | ActionGrowPaneHeight | |
| 44 | + | ActionShrinkPaneHeight | |
| 45 | + | ActionEqualizePanes | |
| 41 | 46 | ) | |
| 42 | 47 | ||
| 43 | 48 | // 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 | 1 | 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 | 10 | // Solve returns deterministic pane placements for root inside area. | |
| 6 | 11 | func Solve(root *Node, area domain.Rect) ([]Placement, bool) { | |
| 7 | 12 | if root == nil || area.Width <= 0 || area.Height <= 0 { | |
| 8 | 13 | 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 | 23 | var out []Placement | |
| 11 | 24 | if !solve(root, area, &out) { | |
| 12 | 25 | return nil, false | |
| @@ -32,50 +45,207 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool { | |||
| 32 | 45 | } | |
| 33 | 46 | ||
| 34 | 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 | 61 | count := len(n.Children) | |
| 36 | 62 | if count == 0 { | |
| 37 | - | return false | |
| 63 | + | return nil, false | |
| 38 | 64 | } | |
| 39 | 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 { | |
| 43 | - | usable := r.Width - (count - 1) | |
| 44 | - | if usable < count*MinPaneCols || r.Height < MinPaneRows { | |
| 45 | - | return false | |
| 68 | + | ||
| 69 | + | total := r.Width | |
| 70 | + | if n.Dir == Vertical { | |
| 71 | + | 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%count | |
| 48 | - | x := r.X | |
| 49 | - | for i, child := range n.Children { | |
| 50 | - | w := base | |
| 51 | - | if i < rem { | |
| 52 | - | w++ | |
| 137 | + | if n.Dir == axis { | |
| 138 | + | total := len(n.Children) - 1 | |
| 139 | + | for _, child := range n.Children { | |
| 140 | + | minimum, ok := minimumExtent(child, axis) | |
| 141 | + | if !ok { | |
| 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) { | |
| 55 | - | return false | |
| 146 | + | return total, true | |
| 147 | + | } | |
| 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 + 1 | |
| 154 | + | largest = max(largest, minimum) | |
| 58 | 155 | } | |
| 59 | - | return true | |
| 156 | + | return largest, true | |
| 157 | + | default: | |
| 158 | + | return 0, false | |
| 60 | 159 | } | |
| 160 | + | } | |
| 61 | 161 | ||
| 62 | - | usable := r.Height - (count - 1) | |
| 63 | - | if usable < count*MinPaneRows || r.Width < MinPaneCols { | |
| 64 | - | return false | |
| 162 | + | // distribute allocates total integer cells proportionally while respecting each | |
| 163 | + | // child's minimum. It uses stable largest-remainder rounding after all | |
| 164 | + | // 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%count | |
| 67 | - | y := r.Y | |
| 68 | - | for i, child := range n.Children { | |
| 69 | - | h := base | |
| 70 | - | if i < rem { | |
| 71 | - | h++ | |
| 169 | + | minimumTotal := 0 | |
| 170 | + | for _, minimum := range minimums { | |
| 171 | + | if minimum < 0 { | |
| 172 | + | return nil, false | |
| 72 | 173 | } | |
| 73 | - | if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) { | |
| 74 | - | return false | |
| 174 | + | minimumTotal += minimum | |
| 175 | + | } | |
| 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 true | |
| 208 | + | ||
| 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 | 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 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "math" | |
| 5 | 6 | "sort" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| @@ -11,14 +12,198 @@ var ( | |||
| 11 | 12 | ErrNotFound = errors.New("pane not found") | |
| 12 | 13 | ErrTooSmall = errors.New("layout too small") | |
| 13 | 14 | ErrNoPane = errors.New("no pane in direction") | |
| 15 | + | ErrNotInSplit = errors.New("pane is not in a split") | |
| 14 | 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 | 202 | // Split adds newID next to target. The after flag controls whether the new pane | |
| 18 | 203 | // is placed after target on the chosen axis. | |
| 19 | 204 | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | |
| 20 | 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 | 207 | return ErrNotFound | |
| 23 | 208 | } | |
| 24 | 209 | candidate.Focus = newID | |
| @@ -29,14 +214,16 @@ func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, are | |||
| 29 | 214 | 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 | 218 | if n == nil { | |
| 34 | 219 | return false | |
| 35 | 220 | } | |
| 221 | + | childAreas := nodeChildAreas(n, area) | |
| 36 | 222 | for i, child := range n.Children { | |
| 37 | 223 | if child.Kind == Leaf && child.Leaf == target { | |
| 38 | 224 | newLeaf := NewLeaf(newID) | |
| 39 | 225 | if n.Kind == Split && n.Dir == axis { | |
| 226 | + | normalizeChildWeightsFromArea(n, area) | |
| 40 | 227 | at := i | |
| 41 | 228 | if after { | |
| 42 | 229 | at++ | |
| @@ -47,36 +234,82 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID | |||
| 47 | 234 | return true | |
| 48 | 235 | } | |
| 49 | 236 | if n.Kind == Stack { | |
| 237 | + | weight := n.Weight | |
| 50 | 238 | stack := n.clone() | |
| 239 | + | stack.Weight = 0 | |
| 240 | + | clearChildWeights(stack) | |
| 51 | 241 | children := []*Node{stack, newLeaf} | |
| 52 | 242 | if !after { | |
| 53 | 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 | 246 | return true | |
| 57 | 247 | } | |
| 248 | + | weight := child.Weight | |
| 249 | + | child.Weight = 0 | |
| 58 | 250 | children := []*Node{child, newLeaf} | |
| 59 | 251 | if !after { | |
| 60 | 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 | 255 | 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 | 262 | return true | |
| 67 | 263 | } | |
| 68 | 264 | } | |
| 69 | 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 | 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 | 274 | return true | |
| 76 | 275 | } | |
| 77 | 276 | 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 | 313 | // StackNew puts newID in target's stack, creating one if target is not stacked. | |
| 81 | 314 | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | |
| 82 | 315 | candidate := t.clone() | |
| @@ -100,13 +333,16 @@ func stackNew(n *Node, target, newID PaneID) bool { | |||
| 100 | 333 | if child.Kind == Leaf && child.Leaf == target { | |
| 101 | 334 | n.Children = append(n.Children, NewLeaf(newID)) | |
| 102 | 335 | n.Expanded = newID | |
| 336 | + | clearChildWeights(n) | |
| 103 | 337 | return true | |
| 104 | 338 | } | |
| 105 | 339 | } | |
| 106 | 340 | } | |
| 107 | 341 | for i, child := range n.Children { | |
| 108 | 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 | 346 | return true | |
| 111 | 347 | } | |
| 112 | 348 | if stackNew(child, target, newID) { | |
| @@ -114,7 +350,8 @@ func stackNew(n *Node, target, newID PaneID) bool { | |||
| 114 | 350 | } | |
| 115 | 351 | } | |
| 116 | 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 | 355 | return true | |
| 119 | 356 | } | |
| 120 | 357 | return false | |
| @@ -169,11 +406,13 @@ func toggleStack(n *Node, target PaneID) toggleResult { | |||
| 169 | 406 | n.Kind = Split | |
| 170 | 407 | n.Dir = Vertical | |
| 171 | 408 | n.Expanded = "" | |
| 409 | + | clearChildWeights(n) | |
| 172 | 410 | return toggleToggled | |
| 173 | 411 | } | |
| 174 | 412 | if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) { | |
| 175 | 413 | n.Kind = Stack | |
| 176 | 414 | n.Expanded = target | |
| 415 | + | clearChildWeights(n) | |
| 177 | 416 | return toggleToggled | |
| 178 | 417 | } | |
| 179 | 418 | return toggleNotToggleable | |
| @@ -252,16 +491,13 @@ func closeNode(n *Node, target PaneID) (*Node, bool) { | |||
| 252 | 491 | if !removed { | |
| 253 | 492 | return n, false | |
| 254 | 493 | } | |
| 255 | - | if n.Kind == Stack { | |
| 256 | - | if n.Expanded == target && len(n.Children) > 0 { | |
| 257 | - | n.Expanded = firstLeaf(n.Children[0]) | |
| 258 | - | } | |
| 259 | - | if len(n.Children) == 1 { | |
| 260 | - | return n.Children[0], true | |
| 261 | - | } | |
| 494 | + | if n.Kind == Stack && n.Expanded == target && len(n.Children) > 0 { | |
| 495 | + | n.Expanded = firstLeaf(n.Children[0]) | |
| 262 | 496 | } | |
| 263 | - | if n.Kind == Split && len(n.Children) == 1 { | |
| 264 | - | return n.Children[0], true | |
| 497 | + | if (n.Kind == Stack || n.Kind == Split) && len(n.Children) == 1 { | |
| 498 | + | promoted := n.Children[0] | |
| 499 | + | promoted.Weight = n.Weight | |
| 500 | + | return promoted, true | |
| 265 | 501 | } | |
| 266 | 502 | 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 | 37 | 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 | 48 | // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir | |
| 41 | 49 | // and Children are meaningful for Split nodes; Children and Expanded are | |
| 42 | 50 | // meaningful for Stack nodes. | |
| @@ -46,6 +54,7 @@ type Node struct { | |||
| 46 | 54 | Children []*Node | |
| 47 | 55 | Leaf PaneID | |
| 48 | 56 | Expanded PaneID | |
| 57 | + | Weight float64 | |
| 49 | 58 | } | |
| 50 | 59 | ||
| 51 | 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 | 30 | processAbsent uint8 = 0 | |
| 31 | 31 | processPresent uint8 = 1 | |
| 32 | + | ||
| 33 | + | manifestWeightTag = "WGT1" | |
| 34 | + | manifestWeightSize = 8 | |
| 32 | 35 | // maxDecodedBodySize includes the object kind and payload-length prefix. | |
| 33 | 36 | maxObjectEnvelopeSize = manifestHeaderSize + maxDecodedBodySize | |
| 34 | 37 | maxObjectPayloadSize = maxDecodedBodySize - objectEnvelopeBodyPrefixSize | |
| @@ -140,6 +143,9 @@ func MarshalManifest(m Manifest) ([]byte, error) { | |||
| 140 | 143 | return nil, err | |
| 141 | 144 | } | |
| 142 | 145 | } | |
| 146 | + | if err := writeManifestWeights(&w, manifestNodes(m)); err != nil { | |
| 147 | + | return nil, err | |
| 148 | + | } | |
| 143 | 149 | return marshalManifestEnvelope(manifestMagic, w.b) | |
| 144 | 150 | } | |
| 145 | 151 | ||
| @@ -184,7 +190,7 @@ func decodeManifest(body []byte) (Manifest, error) { | |||
| 184 | 190 | return Manifest{}, err | |
| 185 | 191 | } | |
| 186 | 192 | } | |
| 187 | - | if err := r.done(); err != nil { | |
| 193 | + | if err := readManifestWeights(&r, manifestNodes(m)); err != nil { | |
| 188 | 194 | return Manifest{}, err | |
| 189 | 195 | } | |
| 190 | 196 | if err := validateManifest(m); err != nil { | |
| @@ -316,6 +322,100 @@ func writeManifestTab(w *payloadWriter, tab ManifestTab) error { | |||
| 316 | 322 | } | |
| 317 | 323 | 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 | 419 | func writeManifestPane(w *payloadWriter, pane ManifestPane) error { | |
| 320 | 420 | if err := w.putString(string(pane.ID)); err != nil { | |
| 321 | 421 | return err | |
| @@ -448,6 +548,11 @@ func validateManifest(m Manifest) error { | |||
| 448 | 548 | if err := validateActive(m.Active, len(m.Tabs)); err != nil { | |
| 449 | 549 | 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 | 556 | for _, tab := range m.Tabs { | |
| 452 | 557 | if !safeDimensions(tab.Cols, tab.Rows) { | |
| 453 | 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 | 43 | 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 | 54 | func (b *manifestPreflightBudget) add(n uint64) bool { | |
| 52 | 55 | if n > maxSnapshotDecodedAllocation-b.allocation { | |
| @@ -158,6 +161,7 @@ func preflightManifestNode(r *payloadReader, depth int, root bool, budget *manif | |||
| 158 | 161 | if err != nil { | |
| 159 | 162 | return err | |
| 160 | 163 | } | |
| 164 | + | budget.nodes++ | |
| 161 | 165 | *refs = append(*refs, layout.PaneID(leaf)) | |
| 162 | 166 | return nil | |
| 163 | 167 | case manifestNodeSplit: | |
| @@ -184,6 +188,7 @@ func preflightManifestNode(r *payloadReader, depth int, root bool, budget *manif | |||
| 184 | 188 | default: | |
| 185 | 189 | return fmt.Errorf("%w: node kind", ErrInvalidData) | |
| 186 | 190 | } | |
| 191 | + | budget.nodes++ | |
| 187 | 192 | n, err := r.getUint16() | |
| 188 | 193 | if err != nil { | |
| 189 | 194 | return err | |
| @@ -199,6 +204,10 @@ func preflightManifestNode(r *payloadReader, depth int, root bool, budget *manif | |||
| 199 | 204 | return nil | |
| 200 | 205 | } | |
| 201 | 206 | ||
| 207 | + | func preflightManifestWeights(r *payloadReader, wantNodes uint32) error { | |
| 208 | + | return readManifestWeightExtension(r, uint64(wantNodes), nil) | |
| 209 | + | } | |
| 210 | + | ||
| 202 | 211 | func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (layout.PaneID, error) { | |
| 203 | 212 | id, err := preflightManifestString(r, budget) | |
| 204 | 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 | + | } | |