diff --git a/cmux-tui/Cargo.lock b/cmux-tui/Cargo.lock index af09cd193d4d..3ff8b876623f 100644 --- a/cmux-tui/Cargo.lock +++ b/cmux-tui/Cargo.lock @@ -192,7 +192,7 @@ dependencies = [ [[package]] name = "cmux-client" -version = "0.3.0" +version = "0.4.0" dependencies = [ "libc", "serde", diff --git a/cmux-tui/README.md b/cmux-tui/README.md index 280a22a5bdca..f33da0828492 100644 --- a/cmux-tui/README.md +++ b/cmux-tui/README.md @@ -39,6 +39,8 @@ cargo run -p cmux-tui -- attach --session agents --surface The default session is `main`. Default sockets live at `$TMPDIR/cmux-tui-/.sock`; use `--socket ` for an explicit path. Detach from an attached TUI with prefix `d`, which is `Ctrl-b d` by default. +Pane layout stays tiled by default. Press `Ctrl-b g` to append a terminal to the right at two-thirds of the viewport width. The existing layout keeps its width, so a continuous horizontal scrollbar appears in the status bar. Focusing a pane reveals it with an animated viewport movement. `Alt-n` reapplies Zellij's automatic layout inside the focused horizontal column. `Ctrl-b U` undoes the latest structural layout action on the focused screen; undoing pane creation asks for confirmation before closing the pane. + `attach --surface ` attaches one PTY terminal by numeric or short surface id. It uses the full host terminal without the sidebar, status bar, pane border, or other tabs. Packaged builds can run as `npx cmux`. The optional machine rail lets that local client switch among the current session, other Unix sockets, and sessions reached through SSH. It is disabled by default and activates when `machine_sidebar.enabled` is true or `machines` contains a valid entry in `cmux-tui.json`. `npx cmux --cloud` composes those local targets with the Cloud catalog and enables temporary machine connections without sending local SSH details to Cloud. The client uses noninteractive SSH with strict host-key checking and the remote `cmux-tui relay --session ` transport primitive, so the remote headless session, trusted host key, authentication key, and binary must already exist. See [Machines and remote sessions](docs/machines.md). diff --git a/cmux-tui/bindings/RELEASING.md b/cmux-tui/bindings/RELEASING.md index 312b43287972..3a0e9a4306e3 100644 --- a/cmux-tui/bindings/RELEASING.md +++ b/cmux-tui/bindings/RELEASING.md @@ -9,7 +9,7 @@ cmux-sdk-vX.Y.Z Historical releases used `mux-sdk-vX.Y.Z`; the publish workflows still accept that prefix so old release history remains connected. These tags are separate from app release tags such as `vX.Y.Z`. Current SDK package versions are -`0.3.0`. +`0.4.0`. ## One-time registry setup diff --git a/cmux-tui/bindings/conformance/fixtures.json b/cmux-tui/bindings/conformance/fixtures.json index 3d9db9cec46c..5c132c999e1a 100644 --- a/cmux-tui/bindings/conformance/fixtures.json +++ b/cmux-tui/bindings/conformance/fixtures.json @@ -183,6 +183,65 @@ } ] }, + { + "name": "layout-undo-preview-and-confirm", + "requires": { "commands": ["new-pane", "undo-layout"] }, + "steps": [ + { + "type": "command", + "request": { "id": 1, "cmd": "new-workspace", "name": "conf-undo", "cols": 80, "rows": 24 }, + "expect": { "id": 1, "ok": true }, + "match": "partial" + }, + { + "type": "command", + "request": { "id": 2, "cmd": "ids", "kind": "pane" }, + "expect": { "id": 2, "ok": true }, + "match": "partial", + "bind": { "pane0": "data.ids[-1].id" } + }, + { + "type": "command", + "request": { "id": 3, "cmd": "new-pane", "pane": "$pane0" }, + "expect": { "id": 3, "ok": true }, + "match": "partial" + }, + { + "type": "command", + "request": { "id": 4, "cmd": "ids", "kind": "pane" }, + "expect": { "id": 4, "ok": true }, + "match": "partial", + "bind": { "pane1": "data.ids[-1].id" } + }, + { + "type": "command", + "request": { "id": 5, "cmd": "undo-layout", "pane": "$pane1" }, + "expect": { + "id": 5, + "ok": true, + "data": { + "undone": false, + "confirmation_required": true, + "closes_panes": ["$pane1"] + } + }, + "match": "partial", + "bind": { "undo_revision": "data.revision" } + }, + { + "type": "command", + "request": { + "id": 6, + "cmd": "undo-layout", + "pane": "$pane1", + "revision": "$undo_revision", + "confirm_close": true + }, + "expect": { "id": 6, "ok": true, "data": { "undone": true } }, + "match": "partial" + } + ] + }, { "name": "subscribe-tree-changed", "steps": [ diff --git a/cmux-tui/bindings/conformance/runner.py b/cmux-tui/bindings/conformance/runner.py index ae5ffde44901..e3fd39e11ee1 100755 --- a/cmux-tui/bindings/conformance/runner.py +++ b/cmux-tui/bindings/conformance/runner.py @@ -232,9 +232,15 @@ def dispatch(client: CmuxClient, cmd: str, params: Dict[str, Any]) -> Any: "new-browser-tab": client.new_browser_tab, "new-workspace": client.new_workspace, "new-screen": client.new_screen, + "new-pane": client.new_pane, + "new-pane-right": client.new_pane_right, "split": client.split, "set-ratio": client.set_ratio, "set-split-ratio": client.set_split_ratio, + "set-viewport-pane-width": client.set_viewport_pane_width, + "undo-layout": lambda **kw: client.undo_layout( + kw["pane"], kw.get("revision") if kw.get("confirm_close") else None + ), "pane-neighbor": client.pane_neighbor, "focus-direction": client.focus_direction, "swap-pane": client.swap_pane, @@ -374,7 +380,7 @@ def wait_contains(client: CmuxClient, request: Dict[str, Any], path: str, needle def get_path(value: Any, path: str) -> Any: current = value for part in path.split("."): - match = re.fullmatch(r"([A-Za-z0-9_-]+)(?:\[(\d+)\])?", part) + match = re.fullmatch(r"([A-Za-z0-9_-]+)(?:\[(-?\d+)\])?", part) if not match: raise FixtureFailure(f"bad JSON path segment {part!r}") key = match.group(1) @@ -383,7 +389,7 @@ def get_path(value: Any, path: str) -> Any: current = current[key] if match.group(2) is not None: index = int(match.group(2)) - if not isinstance(current, list) or index >= len(current): + if not isinstance(current, list) or index >= len(current) or index < -len(current): raise FixtureFailure(f"path {path!r} missing index {index}") current = current[index] return current diff --git a/cmux-tui/bindings/go/client.go b/cmux-tui/bindings/go/client.go index 0beddbeb6370..41e1593a3c3d 100644 --- a/cmux-tui/bindings/go/client.go +++ b/cmux-tui/bindings/go/client.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "math" "net" "os" "path/filepath" @@ -36,10 +37,62 @@ func validateWorkspaceSelector(workspace *uint64, key *string) error { return nil } +func validateViewportPaneWidth(width float32) error { + value := float64(width) + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0.1 || value > 1.0 { + return fmt.Errorf( + "%w: viewport pane width must be between 0.1 and 1.0", + ErrInvalidArgument, + ) + } + return nil +} + +type layoutUndoWire struct { + Undone *bool `json:"undone"` + ConfirmationRequired *bool `json:"confirmation_required"` + Screen *uint64 `json:"screen"` + Revision *uint64 `json:"revision"` + ClosesPanes *[]uint64 `json:"closes_panes"` +} + +func decodeLayoutUndoResult(wire layoutUndoWire) (LayoutUndoResult, error) { + if wire.Screen == nil { + return nil, &decodeError{msg: "layout undo response omitted screen"} + } + if wire.Revision == nil { + return nil, &decodeError{msg: "layout undo response omitted revision"} + } + + switch { + case wire.Undone != nil && + *wire.Undone && + (wire.ConfirmationRequired == nil || !*wire.ConfirmationRequired): + return LayoutUndoUndone{Screen: *wire.Screen, Revision: *wire.Revision}, nil + case wire.Undone != nil && + !*wire.Undone && + wire.ConfirmationRequired != nil && + *wire.ConfirmationRequired: + if wire.ClosesPanes == nil { + return nil, &decodeError{ + msg: "layout undo confirmation closes_panes must be an array of pane IDs", + } + } + return LayoutUndoConfirmationRequired{ + Screen: *wire.Screen, Revision: *wire.Revision, ClosesPanes: *wire.ClosesPanes, + }, nil + default: + return nil, &decodeError{ + msg: "layout undo response does not contain exactly one valid outcome", + } + } +} + type CommandError struct { - Message string - ID any - Delivery ErrorDelivery + Message string + ID any + ErrorCode string + Delivery ErrorDelivery } func (e *CommandError) Error() string { return e.Message } @@ -59,11 +112,13 @@ func commandErrorFromResponse(response map[string]any) *CommandError { if msg == "" { msg = "unknown error" } + code, _ := response["error_code"].(string) delivery, _ := response["error_delivery"].(string) return &CommandError{ - Message: msg, - ID: response["id"], - Delivery: ErrorDelivery(delivery), + Message: msg, + ID: response["id"], + ErrorCode: code, + Delivery: ErrorDelivery(delivery), } } @@ -440,6 +495,29 @@ func (c *Client) NewPane(ctx context.Context, pane uint64, opts NewPaneOptions) return result, c.request(ctx, "new-pane", params, &result) } +func (c *Client) NewPaneRight(ctx context.Context, pane uint64, opts NewPaneRightOptions) (SurfaceResult, error) { + if opts.Width != nil { + if err := validateViewportPaneWidth(*opts.Width); err != nil { + return SurfaceResult{}, err + } + } + if err := c.requireCapability(ctx, "viewport-splits-v1", "viewport panes"); err != nil { + return SurfaceResult{}, err + } + params := map[string]any{"pane": pane} + if opts.Width != nil { + params["width"] = *opts.Width + } + if opts.Cols != nil { + params["cols"] = *opts.Cols + } + if opts.Rows != nil { + params["rows"] = *opts.Rows + } + var result SurfaceResult + return result, c.request(ctx, "new-pane-right", params, &result) +} + func (c *Client) Split(ctx context.Context, pane uint64, dir string, opts SplitOptions) (SurfaceResult, error) { params := commandMap(opts) params["pane"] = pane @@ -453,10 +531,65 @@ func (c *Client) SetRatio(ctx context.Context, pane uint64, dir string, ratio fl } func (c *Client) SetSplitRatio(ctx context.Context, split uint64, ratio float32) error { + return c.setSplitRatio(ctx, split, ratio, nil) +} + +// SetSplitRatioInTransaction coalesces samples that share one client-scoped transaction. +func (c *Client) SetSplitRatioInTransaction(ctx context.Context, split uint64, ratio float32, transaction uint64) error { + return c.setSplitRatio(ctx, split, ratio, &transaction) +} + +func (c *Client) setSplitRatio(ctx context.Context, split uint64, ratio float32, transaction *uint64) error { if err := c.requireProtocol(ctx, 8, "set-split-ratio"); err != nil { return err } - return c.request(ctx, "set-split-ratio", map[string]any{"split": split, "ratio": ratio}, nil) + params := map[string]any{"split": split, "ratio": ratio} + if transaction != nil { + params["transaction"] = *transaction + } + return c.request(ctx, "set-split-ratio", params, nil) +} + +func (c *Client) SetViewportPaneWidth(ctx context.Context, pane uint64, width float32) error { + return c.setViewportPaneWidth(ctx, pane, width, nil) +} + +// SetViewportPaneWidthInTransaction coalesces samples that share one client-scoped transaction. +func (c *Client) SetViewportPaneWidthInTransaction(ctx context.Context, pane uint64, width float32, transaction uint64) error { + return c.setViewportPaneWidth(ctx, pane, width, &transaction) +} + +func (c *Client) setViewportPaneWidth(ctx context.Context, pane uint64, width float32, transaction *uint64) error { + if err := validateViewportPaneWidth(width); err != nil { + return err + } + if err := c.requireCapability(ctx, "viewport-column-resize-v1", "viewport pane resizing"); err != nil { + return err + } + params := map[string]any{"pane": pane, "width": width} + if transaction != nil { + params["transaction"] = *transaction + } + return c.request(ctx, "set-viewport-pane-width", params, nil) +} + +// UndoLayout previews the latest layout undo when confirmationRevision is nil. +// To confirm a pane-closing undo, pass the exact revision returned by +// LayoutUndoConfirmationRequired. +func (c *Client) UndoLayout(ctx context.Context, pane uint64, confirmationRevision *uint64) (LayoutUndoResult, error) { + if err := c.requireCapability(ctx, "layout-undo-v1", "layout undo"); err != nil { + return nil, err + } + params := map[string]any{"pane": pane} + if confirmationRevision != nil { + params["revision"] = *confirmationRevision + params["confirm_close"] = true + } + var wire layoutUndoWire + if err := c.request(ctx, "undo-layout", params, &wire); err != nil { + return nil, err + } + return decodeLayoutUndoResult(wire) } func (c *Client) SetDefaultColors(ctx context.Context, fg, bg *string) error { diff --git a/cmux-tui/bindings/go/client_test.go b/cmux-tui/bindings/go/client_test.go index 28c47ced8d28..5e773b0bbec1 100644 --- a/cmux-tui/bindings/go/client_test.go +++ b/cmux-tui/bindings/go/client_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "io" + "math" "net" "strings" "sync" @@ -57,6 +58,40 @@ func TestClientInfoNormalizesProtocolNineSizingParticipation(t *testing.T) { } } +func TestCommandErrorPreservesMachineReadableCode(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + client := &Client{ + timeout: time.Second, + conn: &jsonLineConn{conn: clientConn, reader: bufio.NewReader(clientConn)}, + } + defer client.Close() + + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + var request map[string]any + if decoder.Decode(&request) != nil { + return + } + _ = encoder.Encode(map[string]any{ + "id": request["id"], + "ok": false, + "error": "layout changed", + "error_code": "layout-undo-stale", + }) + }() + + err := client.request(context.Background(), "undo-layout", nil, nil) + var commandError *CommandError + if !errors.As(err, &commandError) { + t.Fatalf("error = %v, want CommandError", err) + } + if commandError.ErrorCode != "layout-undo-stale" { + t.Fatalf("error code = %q, want layout-undo-stale", commandError.ErrorCode) + } +} + func TestWorkspaceRegistryTypesDecode(t *testing.T) { var tree Tree if err := json.Unmarshal([]byte(`{"workspace_revision":4,"pane_revision":7,"workspaces":[{"id":1,"key":"stable","name":"one","active":true,"screens":[]}]}`), &tree); err != nil { @@ -72,6 +107,15 @@ func TestWorkspaceRegistryTypesDecode(t *testing.T) { if legacyTree.PaneRevision != nil { t.Fatalf("legacy pane revision = %v, want nil", legacyTree.PaneRevision) } + var viewportTree Tree + if err := json.Unmarshal([]byte(`{"workspaces":[{"id":1,"name":"one","active":true,"screens":[{"id":2,"name":null,"active":true,"active_pane":3,"layout":{"type":"leaf","pane":3},"viewport_base_width":0.75,"viewport_splits":[{"split":4,"width":0.5}],"panes":[]}]}]}`), &viewportTree); err != nil { + t.Fatal(err) + } + viewport := viewportTree.Workspaces[0].Screens[0] + if viewport.ViewportBaseWidth == nil || *viewport.ViewportBaseWidth != 0.75 || + len(viewport.ViewportSplits) != 1 || viewport.ViewportSplits[0] != (ViewportSplit{Split: 4, Width: 0.5}) { + t.Fatalf("viewport screen = %#v", viewport) + } var placement WorkspacePlacement if err := json.Unmarshal([]byte(`{"workspace":1,"key":"stable","index":0,"workspace_revision":5}`), &placement); err != nil { @@ -101,6 +145,114 @@ func TestCreateTerminalPreservesExplicitlyEmptyArgv(t *testing.T) { } } +func TestNewPaneRightRejectsInvalidWidthsWithoutSendingACommand(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + protocol := uint32(10) + client := &Client{ + timeout: time.Second, + conn: &jsonLineConn{conn: clientConn, reader: bufio.NewReader(clientConn)}, + protocol: &protocol, + capabilities: map[string]struct{}{"viewport-splits-v1": {}}, + } + defer client.Close() + + requests := make(chan map[string]any, 1) + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + for { + var request map[string]any + if decoder.Decode(&request) != nil { + return + } + requests <- request + _ = encoder.Encode(map[string]any{ + "id": request["id"], + "ok": true, + "data": map[string]any{"surface": 9}, + }) + } + }() + + for _, width := range []float32{ + float32(math.NaN()), + float32(math.Inf(1)), + float32(math.Inf(-1)), + 0.09, + 1.01, + } { + _, err := client.NewPaneRight( + context.Background(), + 7, + NewPaneRightOptions{Width: &width}, + ) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("NewPaneRight(width=%v) error = %v, want invalid argument", width, err) + } + } + + select { + case request := <-requests: + t.Fatalf("invalid width sent request: %#v", request) + default: + } +} + +func TestSetViewportPaneWidthRejectsInvalidWidthsWithoutSendingACommand(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + protocol := uint32(10) + client := &Client{ + timeout: time.Second, + conn: &jsonLineConn{conn: clientConn, reader: bufio.NewReader(clientConn)}, + protocol: &protocol, + capabilities: map[string]struct{}{"viewport-column-resize-v1": {}}, + } + defer client.Close() + + requests := make(chan map[string]any, 1) + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + for { + var request map[string]any + if decoder.Decode(&request) != nil { + return + } + requests <- request + _ = encoder.Encode(map[string]any{ + "id": request["id"], + "ok": true, + "data": map[string]any{}, + }) + } + }() + + for _, width := range []float32{ + float32(math.NaN()), + float32(math.Inf(1)), + float32(math.Inf(-1)), + 0.09, + 1.01, + } { + err := client.SetViewportPaneWidth(context.Background(), 7, width) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf( + "SetViewportPaneWidth(width=%v) error = %v, want invalid argument", + width, + err, + ) + } + } + + select { + case request := <-requests: + t.Fatalf("invalid width sent request: %#v", request) + default: + } +} + func TestWorkspaceRegistrySelectorsRejectMissingAndEmptyKeysLocally(t *testing.T) { if err := validateWorkspaceSelector(nil, nil); !errors.Is(err, ErrInvalidArgument) { t.Fatalf("missing selector error = %v", err) @@ -405,6 +557,201 @@ func TestSetSplitRatioAcceptsNewerAdditiveProtocols(t *testing.T) { } } +func TestResizeMethodsForwardExplicitTransactions(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + protocol := uint32(10) + client := &Client{ + timeout: time.Second, + conn: &jsonLineConn{conn: clientConn, reader: bufio.NewReader(clientConn)}, + protocol: &protocol, + capabilities: map[string]struct{}{"viewport-column-resize-v1": {}}, + } + defer client.Close() + + requests := make(chan map[string]any, 2) + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + for range 2 { + var request map[string]any + if decoder.Decode(&request) != nil { + return + } + requests <- request + _ = encoder.Encode(map[string]any{"id": request["id"], "ok": true, "data": map[string]any{}}) + } + }() + + if err := client.SetSplitRatioInTransaction(context.Background(), 42, 0.6, 17); err != nil { + t.Fatal(err) + } + if err := client.SetViewportPaneWidthInTransaction(context.Background(), 9, 0.75, 17); err != nil { + t.Fatal(err) + } + + split := <-requests + if split["cmd"] != "set-split-ratio" || split["transaction"] != float64(17) { + t.Fatalf("split request = %#v", split) + } + column := <-requests + if column["cmd"] != "set-viewport-pane-width" || column["transaction"] != float64(17) { + t.Fatalf("column request = %#v", column) + } +} + +func TestUndoLayoutPreservesPreviewRevisionForConfirmation(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + protocol := uint32(10) + client := &Client{ + timeout: time.Second, + conn: &jsonLineConn{conn: clientConn, reader: bufio.NewReader(clientConn)}, + protocol: &protocol, + capabilities: map[string]struct{}{"layout-undo-v1": {}}, + } + defer client.Close() + + requests := make(chan map[string]any, 2) + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + for range 2 { + var request map[string]any + if decoder.Decode(&request) != nil { + return + } + requests <- request + data := map[string]any{ + "undone": false, "confirmation_required": true, + "screen": 3, "revision": 8, "closes_panes": []uint64{15}, + } + if request["confirm_close"] == true { + data = map[string]any{"undone": true, "screen": 3, "revision": 9} + } + _ = encoder.Encode(map[string]any{"id": request["id"], "ok": true, "data": data}) + } + }() + + preview, err := client.UndoLayout(context.Background(), 15, nil) + if err != nil { + t.Fatal(err) + } + confirmation, ok := preview.(LayoutUndoConfirmationRequired) + if !ok || confirmation.Revision != 8 || len(confirmation.ClosesPanes) != 1 { + t.Fatalf("preview = %#v", preview) + } + result, err := client.UndoLayout(context.Background(), 15, &confirmation.Revision) + if err != nil { + t.Fatal(err) + } + if _, ok := result.(LayoutUndoUndone); !ok { + t.Fatalf("result = %#v", result) + } + <-requests + confirm := <-requests + if confirm["revision"] != float64(8) || confirm["confirm_close"] != true { + t.Fatalf("confirmation request = %#v", confirm) + } +} + +func TestUndoLayoutRejectsMissingClosesPanes(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + protocol := uint32(10) + client := &Client{ + timeout: time.Second, + conn: &jsonLineConn{conn: clientConn, reader: bufio.NewReader(clientConn)}, + protocol: &protocol, + capabilities: map[string]struct{}{"layout-undo-v1": {}}, + } + defer client.Close() + + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + var request map[string]any + if decoder.Decode(&request) != nil { + return + } + _ = encoder.Encode(map[string]any{ + "id": request["id"], + "ok": true, + "data": map[string]any{ + "undone": false, "confirmation_required": true, + "screen": 3, "revision": 8, + }, + }) + }() + + _, err := client.UndoLayout(context.Background(), 15, nil) + if err == nil || !errors.Is(err, ErrDecode) { + t.Fatalf("UndoLayout() error = %v, want decode error", err) + } +} + +func TestUndoLayoutRejectsMalformedResultVariants(t *testing.T) { + tests := map[string]map[string]any{ + "missing undone discriminator": { + "confirmation_required": true, + "screen": 3, + "revision": 8, + "closes_panes": []uint64{15}, + }, + "missing screen": { + "undone": false, + "confirmation_required": true, + "revision": 8, + "closes_panes": []uint64{15}, + }, + "missing revision": { + "undone": false, + "confirmation_required": true, + "screen": 3, + "closes_panes": []uint64{15}, + }, + "contradictory outcomes": { + "undone": true, + "confirmation_required": true, + "screen": 3, + "revision": 8, + "closes_panes": []uint64{15}, + }, + } + + for name, data := range tests { + t.Run(name, func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + protocol := uint32(10) + client := &Client{ + timeout: time.Second, + conn: &jsonLineConn{conn: clientConn, reader: bufio.NewReader(clientConn)}, + protocol: &protocol, + capabilities: map[string]struct{}{"layout-undo-v1": {}}, + } + defer client.Close() + + go func() { + decoder := json.NewDecoder(serverConn) + encoder := json.NewEncoder(serverConn) + var request map[string]any + if decoder.Decode(&request) != nil { + return + } + _ = encoder.Encode(map[string]any{ + "id": request["id"], "ok": true, "data": data, + }) + }() + + _, err := client.UndoLayout(context.Background(), 15, nil) + if err == nil || !errors.Is(err, ErrDecode) { + t.Fatalf("UndoLayout() error = %v, want decode error", err) + } + }) + } +} + func TestNewPaneRejectsServersOlderThanProtocolNine(t *testing.T) { protocol := uint32(8) client := &Client{protocol: &protocol} diff --git a/cmux-tui/bindings/go/cmd/e2e/main.go b/cmux-tui/bindings/go/cmd/e2e/main.go index 5c03161bc93e..5f8349b9a25e 100644 --- a/cmux-tui/bindings/go/cmd/e2e/main.go +++ b/cmux-tui/bindings/go/cmd/e2e/main.go @@ -71,6 +71,24 @@ func run() error { if !ok { return fmt.Errorf("workspace not found") } + pane, ok := findPaneForSurface(tree, created.Surface) + if !ok { + return fmt.Errorf("pane not found") + } + width := float32(0.5) + if _, err := client.NewPaneRight(ctx, pane, cmux.NewPaneRightOptions{Width: &width}); err != nil { + return err + } + viewportTree, err := client.ListWorkspaces(ctx) + if err != nil { + return err + } + viewportScreen, ok := findScreenForSurface(viewportTree, created.Surface) + if !ok || viewportScreen.ViewportBaseWidth == nil || *viewportScreen.ViewportBaseWidth != 1 || + len(viewportScreen.ViewportSplits) != 1 || + absFloat32(viewportScreen.ViewportSplits[0].Width-0.5) >= 0.0001 { + return fmt.Errorf("viewport metadata missing or incorrect: %+v", viewportScreen) + } if err := client.RenameSurface(ctx, created.Surface, marker+"-renamed"); err != nil { return err } @@ -222,6 +240,43 @@ func findWorkspaceForSurface(tree cmux.Tree, surface uint64) (uint64, bool) { return 0, false } +func findPaneForSurface(tree cmux.Tree, surface uint64) (uint64, bool) { + for _, workspace := range tree.Workspaces { + for _, screen := range workspace.Screens { + for _, pane := range screen.Panes { + for _, tab := range pane.Tabs { + if tab.Surface == surface { + return pane.ID, true + } + } + } + } + } + return 0, false +} + +func findScreenForSurface(tree cmux.Tree, surface uint64) (cmux.Screen, bool) { + for _, workspace := range tree.Workspaces { + for _, screen := range workspace.Screens { + for _, pane := range screen.Panes { + for _, tab := range pane.Tabs { + if tab.Surface == surface { + return screen, true + } + } + } + } + } + return cmux.Screen{}, false +} + +func absFloat32(value float32) float32 { + if value < 0 { + return -value + } + return value +} + func findClientSurfaceSize( ctx context.Context, client *cmux.Client, diff --git a/cmux-tui/bindings/go/types.go b/cmux-tui/bindings/go/types.go index ea57c78df04f..71b39a152b9d 100644 --- a/cmux-tui/bindings/go/types.go +++ b/cmux-tui/bindings/go/types.go @@ -83,6 +83,25 @@ type SurfaceResult struct { Surface uint64 `json:"surface"` } +type LayoutUndoResult interface { + isLayoutUndoResult() +} + +type LayoutUndoUndone struct { + Screen uint64 + Revision uint64 +} + +func (LayoutUndoUndone) isLayoutUndoResult() {} + +type LayoutUndoConfirmationRequired struct { + Screen uint64 + Revision uint64 + ClosesPanes []uint64 +} + +func (LayoutUndoConfirmationRequired) isLayoutUndoResult() {} + type ReadScreenResult struct { Text string `json:"text"` } @@ -270,13 +289,20 @@ type Workspace struct { Screens []Screen `json:"screens"` } +type ViewportSplit struct { + Split uint64 `json:"split"` + Width float32 `json:"width"` +} + type Screen struct { - ID uint64 `json:"id"` - Name *string `json:"name"` - Active bool `json:"active"` - ActivePane uint64 `json:"active_pane"` - Layout any `json:"layout"` - Panes []Pane `json:"panes"` + ID uint64 `json:"id"` + Name *string `json:"name"` + Active bool `json:"active"` + ActivePane uint64 `json:"active_pane"` + Layout any `json:"layout"` + ViewportBaseWidth *float32 `json:"viewport_base_width,omitempty"` + ViewportSplits []ViewportSplit `json:"viewport_splits,omitempty"` + Panes []Pane `json:"panes"` } type Pane struct { @@ -383,6 +409,12 @@ type NewPaneOptions struct { Rows *uint16 `json:"rows,omitempty"` } +type NewPaneRightOptions struct { + Width *float32 `json:"width,omitempty"` + Cols *uint16 `json:"cols,omitempty"` + Rows *uint16 `json:"rows,omitempty"` +} + type SplitOptions struct { Cols *uint16 `json:"cols,omitempty"` Rows *uint16 `json:"rows,omitempty"` diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java index aa03a989dc9a..d3c000006376 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxClient.java @@ -255,6 +255,22 @@ public SurfaceResult newPane(long pane, Integer cols, Integer rows) throws CmuxE return new SurfaceResult(asLong(request("new-pane", params).get("surface"))); } + public SurfaceResult newPaneRight( + long pane, + Double width, + Integer cols, + Integer rows + ) throws CmuxException { + validateViewportPaneWidth(width); + requireCapability("viewport-splits-v1", "viewport panes"); + Map params = new LinkedHashMap<>(); + params.put("pane", pane); + putIfNotNull(params, "width", width); + putIfNotNull(params, "cols", cols); + putIfNotNull(params, "rows", rows); + return new SurfaceResult(asLong(request("new-pane-right", params).get("surface"))); + } + public SurfaceResult split(long pane, String dir, Integer cols, Integer rows) throws CmuxException { Map params = new LinkedHashMap<>(); params.put("pane", pane); @@ -273,13 +289,81 @@ public void setRatio(long pane, String dir, double ratio) throws CmuxException { } public void setSplitRatio(long split, double ratio) throws CmuxException { + setSplitRatio(split, ratio, null); + } + + public void setSplitRatio(long split, double ratio, Long transaction) throws CmuxException { requireProtocol(8, "set-split-ratio"); Map params = new LinkedHashMap<>(); params.put("split", split); params.put("ratio", ratio); + putIfNotNull(params, "transaction", transaction); request("set-split-ratio", params); } + public void setViewportPaneWidth(long pane, double width) throws CmuxException { + setViewportPaneWidth(pane, width, null); + } + + public void setViewportPaneWidth(long pane, double width, Long transaction) + throws CmuxException { + validateViewportPaneWidth(width); + requireCapability("viewport-column-resize-v1", "viewport pane resizing"); + Map params = new LinkedHashMap<>(); + params.put("pane", pane); + params.put("width", width); + putIfNotNull(params, "transaction", transaction); + request("set-viewport-pane-width", params); + } + + /** + * Previews layout undo when confirmationRevision is null. Confirm a + * pane-closing undo by passing the exact revision from the preview. + */ + public LayoutUndoResult undoLayout(long pane, Long confirmationRevision) + throws CmuxException { + requireCapability("layout-undo-v1", "layout undo"); + Map params = new LinkedHashMap<>(); + params.put("pane", pane); + if (confirmationRevision != null) { + params.put("revision", confirmationRevision); + params.put("confirm_close", true); + } + return decodeLayoutUndoResult(request("undo-layout", params)); + } + + private static LayoutUndoResult decodeLayoutUndoResult(Map data) + throws CmuxDecodeException { + long screen = layoutUndoU64(data.get("screen"), "screen"); + long revision = layoutUndoU64(data.get("revision"), "revision"); + Object undone = data.get("undone"); + Object confirmationRequired = data.get("confirmation_required"); + if (Boolean.TRUE.equals(undone) + && (!data.containsKey("confirmation_required") + || Boolean.FALSE.equals(confirmationRequired))) { + return new LayoutUndoUndone(screen, revision); + } + if (Boolean.FALSE.equals(undone) + && Boolean.TRUE.equals(confirmationRequired)) { + Object rawPanes = data.get("closes_panes"); + if (!(rawPanes instanceof List panes)) { + throw new CmuxDecodeException( + "layout undo confirmation closes_panes must be an array of pane IDs", + null + ); + } + List closesPanes = new ArrayList<>(); + for (Object value : panes) { + closesPanes.add(layoutUndoU64(value, "pane ID")); + } + return new LayoutUndoConfirmationRequired(screen, revision, List.copyOf(closesPanes)); + } + throw new CmuxDecodeException( + "layout undo response does not contain exactly one valid outcome", + null + ); + } + public void setDefaultColors(String fg, String bg) throws CmuxException { Map params = new LinkedHashMap<>(); putIfNotNull(params, "fg", fg); @@ -495,11 +579,7 @@ private Map request(String cmd, Map params) thro } return new LinkedHashMap<>(); } - throw new CmuxCommandException( - asString(response.getOrDefault("error", "unknown error")), - response.get("id"), - CmuxErrorDelivery.fromWire(response.get("error_delivery")) - ); + throw commandException(response); } private List requestList(String cmd, Map params) throws CmuxException { @@ -514,11 +594,7 @@ private List requestList(String cmd, Map params) throws CmuxE } throw new CmuxDecodeException(cmd + " returned non-array data", null); } - throw new CmuxCommandException( - asString(response.getOrDefault("error", "unknown error")), - response.get("id"), - CmuxErrorDelivery.fromWire(response.get("error_delivery")) - ); + throw commandException(response); } private long nextId() { @@ -542,10 +618,31 @@ static void putIfNotNull(Map params, String key, Object value) { } } + static void validateViewportPaneWidth(Double width) { + if (width == null) { + return; + } + if (!Double.isFinite(width) || width < 0.1 || width > 1.0) { + throw new IllegalArgumentException( + "viewport pane width must be between 0.1 and 1.0" + ); + } + } + static String asString(Object value) { return value == null ? "" : String.valueOf(value); } + static CmuxCommandException commandException(Map response) { + String errorCode = response.get("error_code") instanceof String code ? code : null; + return new CmuxCommandException( + asString(response.getOrDefault("error", "unknown error")), + response.get("id"), + errorCode, + CmuxErrorDelivery.fromWire(response.get("error_delivery")) + ); + } + static long asLong(Object value) { if (value instanceof Number number) { return number.longValue(); @@ -553,6 +650,27 @@ static long asLong(Object value) { return Long.parseLong(String.valueOf(value)); } + private static long layoutUndoU64(Object value, String field) + throws CmuxDecodeException { + if (!(value instanceof Byte + || value instanceof Short + || value instanceof Integer + || value instanceof Long)) { + throw new CmuxDecodeException( + "layout undo " + field + " must be a nonnegative integer", + null + ); + } + long decoded = ((Number) value).longValue(); + if (decoded < 0) { + throw new CmuxDecodeException( + "layout undo " + field + " must be a nonnegative integer", + null + ); + } + return decoded; + } + static boolean idsEqual(Object left, Object right) { if (left instanceof Number leftNumber && right instanceof Number rightNumber) { return Double.compare(leftNumber.doubleValue(), rightNumber.doubleValue()) == 0; @@ -734,11 +852,7 @@ static CmuxStream open(String socketPath, Duration timeout, Map return new CmuxStream(connection, buffered); } if (Boolean.FALSE.equals(response.get("ok"))) { - throw new CmuxCommandException( - asString(response.get("error")), - response.get("id"), - CmuxErrorDelivery.fromWire(response.get("error_delivery")) - ); + throw commandException(response); } } } diff --git a/cmux-tui/bindings/java/src/com/cmux/CmuxCommandException.java b/cmux-tui/bindings/java/src/com/cmux/CmuxCommandException.java index ec09f480e25c..59daebdae4c1 100644 --- a/cmux-tui/bindings/java/src/com/cmux/CmuxCommandException.java +++ b/cmux-tui/bindings/java/src/com/cmux/CmuxCommandException.java @@ -3,20 +3,31 @@ public final class CmuxCommandException extends CmuxException { private final String serverMessage; private final Object commandId; + private final String errorCode; private final CmuxErrorDelivery errorDelivery; public CmuxCommandException(String serverMessage, Object commandId) { - this(serverMessage, commandId, null); + this(serverMessage, commandId, null, null); } public CmuxCommandException( String serverMessage, Object commandId, CmuxErrorDelivery errorDelivery + ) { + this(serverMessage, commandId, null, errorDelivery); + } + + public CmuxCommandException( + String serverMessage, + Object commandId, + String errorCode, + CmuxErrorDelivery errorDelivery ) { super(serverMessage); this.serverMessage = serverMessage; this.commandId = commandId; + this.errorCode = errorCode; this.errorDelivery = errorDelivery; } @@ -28,6 +39,10 @@ public Object commandId() { return commandId; } + public String errorCode() { + return errorCode; + } + public CmuxErrorDelivery errorDelivery() { return errorDelivery; } diff --git a/cmux-tui/bindings/java/src/com/cmux/E2e.java b/cmux-tui/bindings/java/src/com/cmux/E2e.java index f34720c69811..b940045093fe 100644 --- a/cmux-tui/bindings/java/src/com/cmux/E2e.java +++ b/cmux-tui/bindings/java/src/com/cmux/E2e.java @@ -18,7 +18,18 @@ public static void main(String[] args) throws Exception { client.send(created.surface(), "printf '" + marker + "\\n'\r"); waitForMarker(client, created.surface(), marker); check(client.readScreen(created.surface()).text().contains(marker), "marker missing from read-screen"); - long workspace = findWorkspaceForSurface(client.listWorkspaces(), created.surface()); + Tree tree = client.listWorkspaces(); + long workspace = findWorkspaceForSurface(tree, created.surface()); + long pane = findPaneForSurface(tree, created.surface()); + client.newPaneRight(pane, 0.5, null, null); + Screen viewportScreen = findScreenForSurface(client.listWorkspaces(), created.surface()); + check(viewportScreen != null, "viewport screen not found"); + check(Double.valueOf(1.0).equals(viewportScreen.viewportBaseWidth()), "viewport base width missing"); + check(viewportScreen.viewportSplits().size() == 1, "viewport split metadata missing"); + check( + Math.abs(viewportScreen.viewportSplits().get(0).width() - 0.5) < 0.0001, + "viewport split width did not round-trip" + ); client.renameSurface(created.surface(), marker + "-renamed"); try (CmuxClient.CmuxStream events = client.subscribe()) { client.resizeSurface(created.surface(), 100, 31); @@ -117,6 +128,36 @@ private static long findWorkspaceForSurface(Tree tree, long surface) { return -1; } + private static long findPaneForSurface(Tree tree, long surface) { + for (Workspace workspace : tree.workspaces()) { + for (Screen screen : workspace.screens()) { + for (Pane pane : screen.panes()) { + for (Tab tab : pane.tabs()) { + if (tab.surface() == surface) { + return pane.id(); + } + } + } + } + } + return -1; + } + + private static Screen findScreenForSurface(Tree tree, long surface) { + for (Workspace workspace : tree.workspaces()) { + for (Screen screen : workspace.screens()) { + for (Pane pane : screen.panes()) { + for (Tab tab : pane.tabs()) { + if (tab.surface() == surface) { + return screen; + } + } + } + } + } + return null; + } + private static SizingTarget findClientSurfaceSize(CmuxClient client, long surface) throws CmuxException { for (ClientInfo info : client.listClients()) { diff --git a/cmux-tui/bindings/java/src/com/cmux/LayoutUndoConfirmationRequired.java b/cmux-tui/bindings/java/src/com/cmux/LayoutUndoConfirmationRequired.java new file mode 100644 index 000000000000..244b64ef1af7 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/LayoutUndoConfirmationRequired.java @@ -0,0 +1,9 @@ +package com.cmux; + +import java.util.List; + +public record LayoutUndoConfirmationRequired( + long screen, + long revision, + List closesPanes +) implements LayoutUndoResult {} diff --git a/cmux-tui/bindings/java/src/com/cmux/LayoutUndoResult.java b/cmux-tui/bindings/java/src/com/cmux/LayoutUndoResult.java new file mode 100644 index 000000000000..d23b7fe6ac42 --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/LayoutUndoResult.java @@ -0,0 +1,4 @@ +package com.cmux; + +public sealed interface LayoutUndoResult + permits LayoutUndoUndone, LayoutUndoConfirmationRequired {} diff --git a/cmux-tui/bindings/java/src/com/cmux/LayoutUndoUndone.java b/cmux-tui/bindings/java/src/com/cmux/LayoutUndoUndone.java new file mode 100644 index 000000000000..f3333e32f92b --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/LayoutUndoUndone.java @@ -0,0 +1,3 @@ +package com.cmux; + +public record LayoutUndoUndone(long screen, long revision) implements LayoutUndoResult {} diff --git a/cmux-tui/bindings/java/src/com/cmux/Screen.java b/cmux-tui/bindings/java/src/com/cmux/Screen.java index 57d4b8a79f6e..e8cc202ff3d5 100644 --- a/cmux-tui/bindings/java/src/com/cmux/Screen.java +++ b/cmux-tui/bindings/java/src/com/cmux/Screen.java @@ -4,7 +4,27 @@ import java.util.List; import java.util.Map; -public record Screen(long id, String name, boolean active, long activePane, Map layout, List panes) { +public record Screen( + long id, + String name, + boolean active, + long activePane, + Map layout, + List panes, + Double viewportBaseWidth, + List viewportSplits +) { + public Screen( + long id, + String name, + boolean active, + long activePane, + Map layout, + List panes + ) { + this(id, name, active, activePane, layout, panes, null, List.of()); + } + @SuppressWarnings("unchecked") static Screen from(Map data) { List panes = new ArrayList<>(); @@ -14,16 +34,31 @@ static Screen from(Map data) { panes.add(Pane.from((Map) item)); } } + List viewportSplits = new ArrayList<>(); + Object rawViewportSplits = data.get("viewport_splits"); + if (rawViewportSplits instanceof List list) { + for (Object item : list) { + viewportSplits.add(ViewportSplit.from((Map) item)); + } + } Map layout = data.get("layout") instanceof Map rawLayout ? (Map) rawLayout : Map.of(); + Object rawViewportBaseWidth = data.get("viewport_base_width"); + Double viewportBaseWidth = rawViewportBaseWidth instanceof Number number + ? number.doubleValue() + : rawViewportBaseWidth == null + ? null + : Double.parseDouble(String.valueOf(rawViewportBaseWidth)); return new Screen( CmuxClient.asLong(data.get("id")), data.get("name") == null ? null : CmuxClient.asString(data.get("name")), Boolean.TRUE.equals(data.get("active")), CmuxClient.asLong(data.get("active_pane")), layout, - panes + panes, + viewportBaseWidth, + viewportSplits ); } } diff --git a/cmux-tui/bindings/java/src/com/cmux/ViewportSplit.java b/cmux-tui/bindings/java/src/com/cmux/ViewportSplit.java new file mode 100644 index 000000000000..b990770c86ac --- /dev/null +++ b/cmux-tui/bindings/java/src/com/cmux/ViewportSplit.java @@ -0,0 +1,13 @@ +package com.cmux; + +import java.util.Map; + +public record ViewportSplit(long split, double width) { + static ViewportSplit from(Map data) { + Object width = data.get("width"); + double parsedWidth = width instanceof Number number + ? number.doubleValue() + : Double.parseDouble(String.valueOf(width)); + return new ViewportSplit(CmuxClient.asLong(data.get("split")), parsedWidth); + } +} diff --git a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java index be282701dbb3..1ff634375ea3 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/JsonTest.java @@ -32,6 +32,27 @@ public static void main(String[] args) { assertReject("\"\\u1234\""); assertStringifyReject(Double.NaN); assertStringifyReject(Double.POSITIVE_INFINITY); + for (Double width : List.of( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 0.09, + 1.01 + )) { + try { + CmuxClient.validateViewportPaneWidth(width); + throw new AssertionError("accepted invalid viewport width: " + width); + } catch (IllegalArgumentException expectedError) { + assertEquals( + "viewport pane width must be between 0.1 and 1.0", + expectedError.getMessage(), + "viewport width validation" + ); + } + } + CmuxClient.validateViewportPaneWidth(null); + CmuxClient.validateViewportPaneWidth(0.1); + CmuxClient.validateViewportPaneWidth(1.0); CmuxEvent event = CmuxEvent.from((Map) Json.parse( "{\"event\":\"title-changed\",\"surface\":7,\"title\":\"build logs\"}" @@ -78,6 +99,16 @@ public static void main(String[] args) { assertEquals(null, legacyTree.paneRevision(), "legacy pane revision"); Tree revisionedTree = Tree.from(Map.of("pane_revision", 7L, "workspaces", List.of())); assertEquals(7L, revisionedTree.paneRevision(), "pane revision"); + Screen viewportScreen = Screen.from((Map) Json.parse( + "{\"id\":2,\"name\":null,\"active\":true,\"active_pane\":3," + + "\"layout\":{\"type\":\"leaf\",\"pane\":3}," + + "\"viewport_base_width\":0.75," + + "\"viewport_splits\":[{\"split\":4,\"width\":0.5}],\"panes\":[]}" + )); + assertEquals(0.75, viewportScreen.viewportBaseWidth(), "viewport base width"); + assertEquals(1, viewportScreen.viewportSplits().size(), "viewport split count"); + assertEquals(4L, viewportScreen.viewportSplits().get(0).split(), "viewport split id"); + assertEquals(0.5, viewportScreen.viewportSplits().get(0).width(), "viewport split width"); ClientInfo legacyClient = ClientInfo.from((Map) Json.parse( "{\"client\":7,\"transport\":\"ws\",\"connected_seconds\":12," + "\"attached\":[31,32],\"sizes\":[" @@ -99,6 +130,13 @@ public static void main(String[] args) { assertEquals(null, sourceCompatibleTree.paneRevision(), "source-compatible tree pane revision"); Pane sourceCompatiblePane = new Pane(7, "shell", 0, List.of(), false); assertEquals(0L, sourceCompatiblePane.focusedAt(), "source-compatible pane focus recency"); + CmuxCommandException sourceCompatibleCommandError = + new CmuxCommandException("failed", 7L, null); + assertEquals( + null, + sourceCompatibleCommandError.errorDelivery(), + "source-compatible null delivery" + ); CreateTerminalRequest terminalRequest = CreateTerminalRequest.builder() .key("stable") diff --git a/cmux-tui/bindings/java/tests/com/cmux/WireCaptureTest.java b/cmux-tui/bindings/java/tests/com/cmux/WireCaptureTest.java index 3ea53f868e95..184914ddeefc 100644 --- a/cmux-tui/bindings/java/tests/com/cmux/WireCaptureTest.java +++ b/cmux-tui/bindings/java/tests/com/cmux/WireCaptureTest.java @@ -48,7 +48,10 @@ public static void main(String[] args) throws Exception { assertProtocolV7RejectsSetSplitRatio(); assertProtocolV8RejectsNewPane(); assertProtocolV9AllowsSetSplitRatio(); + assertResizeTransactionsAreForwarded(); assertPartialAttachSizeIsRejected(); + assertCommandErrorPreservesMachineReadableCode(); + assertMalformedLayoutUndoResultsAreRejected(); } private static byte[] captureIdentify() throws Exception { @@ -292,6 +295,32 @@ private static void assertProtocolV9AllowsSetSplitRatio() throws Exception { ); } + private static void assertResizeTransactionsAreForwarded() throws Exception { + Path socket = freshSocketPath(); + CaptureServer server = new CaptureServer(socket, new String[] { + "{\"id\":1,\"ok\":true,\"data\":{\"app\":\"cmux-tui\",\"version\":\"test\",\"protocol\":10,\"capabilities\":[\"viewport-column-resize-v1\"],\"session\":\"wire\",\"pid\":1}}", + "{\"id\":2,\"ok\":true,\"data\":{}}", + "{\"id\":3,\"ok\":true,\"data\":{}}" + }, true); + server.start(); + try (CmuxClient client = CmuxClient.builder().socketPath(socket.toString()).timeout(Duration.ofSeconds(2)).build()) { + client.setSplitRatio(4, 0.625, 17L); + client.setViewportPaneWidth(9, 0.75, 17L); + } finally { + server.close(); + } + assertLine( + "transactional set-split-ratio", + "{\"split\":4,\"ratio\":0.625,\"transaction\":17,\"id\":2,\"cmd\":\"set-split-ratio\"}\n", + server.firstLine(1) + ); + assertLine( + "transactional set-viewport-pane-width", + "{\"pane\":9,\"width\":0.75,\"transaction\":17,\"id\":3,\"cmd\":\"set-viewport-pane-width\"}\n", + server.firstLine(2) + ); + } + private static void assertPartialAttachSizeIsRejected() throws Exception { Path socket = freshSocketPath(); CaptureServer server = new CaptureServer(socket, new String[0]); @@ -310,6 +339,54 @@ private static void assertPartialAttachSizeIsRejected() throws Exception { } } + private static void assertCommandErrorPreservesMachineReadableCode() throws Exception { + Path socket = freshSocketPath(); + CaptureServer server = new CaptureServer(socket, new String[] { + "{\"id\":1,\"ok\":false,\"error\":\"layout changed\",\"error_code\":\"layout-undo-stale\"}" + }); + server.start(); + try (CmuxClient client = CmuxClient.builder().socketPath(socket.toString()).timeout(Duration.ofSeconds(2)).build()) { + try { + client.listWorkspaces(); + throw new AssertionError("failed command must throw"); + } catch (CmuxCommandException error) { + if (!"layout-undo-stale".equals(error.errorCode())) { + throw new AssertionError("error code = " + error.errorCode()); + } + } + } finally { + server.close(); + } + } + + private static void assertMalformedLayoutUndoResultsAreRejected() throws Exception { + String[] malformed = new String[] { + "{\"confirmation_required\":true,\"screen\":3,\"revision\":8,\"closes_panes\":[15]}", + "{\"undone\":true,\"confirmation_required\":true,\"screen\":3,\"revision\":8,\"closes_panes\":[15]}", + "{\"undone\":false,\"confirmation_required\":true,\"screen\":\"3\",\"revision\":8,\"closes_panes\":[15]}", + "{\"undone\":false,\"confirmation_required\":true,\"screen\":3,\"revision\":-1,\"closes_panes\":[15]}", + "{\"undone\":false,\"confirmation_required\":true,\"screen\":3,\"revision\":8,\"closes_panes\":[\"15\"]}" + }; + for (String data : malformed) { + Path socket = freshSocketPath(); + CaptureServer server = new CaptureServer(socket, new String[] { + "{\"id\":1,\"ok\":true,\"data\":{\"app\":\"cmux-tui\",\"version\":\"test\",\"protocol\":10,\"capabilities\":[\"layout-undo-v1\"],\"session\":\"wire\",\"pid\":1}}", + "{\"id\":2,\"ok\":true,\"data\":" + data + "}" + }, true); + server.start(); + try (CmuxClient client = CmuxClient.builder().socketPath(socket.toString()).timeout(Duration.ofSeconds(2)).build()) { + try { + client.undoLayout(15, null); + throw new AssertionError("malformed layout undo result must be rejected: " + data); + } catch (CmuxDecodeException expected) { + // Expected. + } + } finally { + server.close(); + } + } + } + private static Path freshSocketPath() throws IOException { Path socket = Files.createTempFile("cmux-java-wire", ".sock"); Files.deleteIfExists(socket); diff --git a/cmux-tui/bindings/python/cmux/__init__.py b/cmux-tui/bindings/python/cmux/__init__.py index a79c5ef128b1..920a6ecf67f3 100644 --- a/cmux-tui/bindings/python/cmux/__init__.py +++ b/cmux-tui/bindings/python/cmux/__init__.py @@ -7,6 +7,9 @@ Event, EventStream, IdentifyResult, + LayoutUndoConfirmationRequired, + LayoutUndoResult, + LayoutUndoUndone, CmuxClient, CmuxConnectionError, CmuxError, @@ -27,6 +30,7 @@ TimeoutError, Tree, VtStateResult, + ViewportSplit, Workspace, WorkspacePlacement, WorkspaceMutation, @@ -41,6 +45,9 @@ "Event", "EventStream", "IdentifyResult", + "LayoutUndoConfirmationRequired", + "LayoutUndoResult", + "LayoutUndoUndone", "CmuxClient", "CmuxConnectionError", "CmuxError", @@ -61,6 +68,7 @@ "TimeoutError", "Tree", "VtStateResult", + "ViewportSplit", "Workspace", "WorkspacePlacement", "WorkspaceMutation", diff --git a/cmux-tui/bindings/python/cmux/client.py b/cmux-tui/bindings/python/cmux/client.py index 7e05439b2dc9..573aee0c2cde 100644 --- a/cmux-tui/bindings/python/cmux/client.py +++ b/cmux-tui/bindings/python/cmux/client.py @@ -2,12 +2,13 @@ import base64 import json +import math import os import socket import tempfile import threading from dataclasses import dataclass, field -from typing import Any, Dict, Iterator, List, Literal, Optional +from typing import Any, Dict, Iterator, List, Literal, Optional, Union TERMINAL_KEY_TEXT_MAX_BYTES = 4 * 1024 @@ -21,6 +22,8 @@ def __init__(self, message: str, response: Optional[Dict[str, Any]] = None): super().__init__(message) self.message = message self.response = response + code = response.get("error_code") if response is not None else None + self.error_code: Optional[str] = code if isinstance(code, str) else None class CmuxConnectionError(CmuxError): @@ -42,6 +45,11 @@ def _validate_workspace_selector(workspace: Optional[int], key: Optional[str]) - raise ValueError("workspace key cannot be empty") +def _validate_viewport_pane_width(width: float) -> None: + if not math.isfinite(width) or not 0.1 <= width <= 1.0: + raise ValueError("viewport pane width must be between 0.1 and 1.0") + + @dataclass(frozen=True) class EmptyResult: pass @@ -282,6 +290,79 @@ class SurfaceResult: surface: int +@dataclass(frozen=True) +class LayoutUndoUndone: + screen: int + revision: int + undone: bool = field(default=True, init=False) + + +@dataclass(frozen=True) +class LayoutUndoConfirmationRequired: + screen: int + revision: int + closes_panes: List[int] + undone: bool = field(default=False, init=False) + confirmation_required: bool = field(default=True, init=False) + + +LayoutUndoResult = Union[LayoutUndoUndone, LayoutUndoConfirmationRequired] + + +def _layout_undo_u64(data: Dict[str, Any], field: str) -> int: + value = data.get(field) + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < 0 + or value > 2**64 - 1 + ): + raise ProtocolError( + f"layout undo {field} must be an unsigned 64-bit integer" + ) + return value + + +def _decode_layout_undo_result(data: Any) -> LayoutUndoResult: + if not isinstance(data, dict): + raise ProtocolError("layout undo result must be an object") + screen = _layout_undo_u64(data, "screen") + revision = _layout_undo_u64(data, "revision") + undone = data.get("undone") + confirmation_required = data.get("confirmation_required") + + if undone is True and ( + "confirmation_required" not in data or confirmation_required is False + ): + return LayoutUndoUndone(screen=screen, revision=revision) + if undone is False and confirmation_required is True: + raw_closes_panes = data.get("closes_panes") + if not isinstance(raw_closes_panes, list): + raise ProtocolError( + "layout undo confirmation closes_panes must be an array of pane IDs" + ) + closes_panes = [] + for value in raw_closes_panes: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < 0 + or value > 2**64 - 1 + ): + raise ProtocolError( + "layout undo confirmation closes_panes must be an array of pane IDs" + ) + closes_panes.append(value) + return LayoutUndoConfirmationRequired( + screen=screen, + revision=revision, + closes_panes=closes_panes, + ) + raise ProtocolError( + "layout undo response does not contain exactly one valid outcome" + ) + + @dataclass(frozen=True) class WorkspacePlacement: workspace: int @@ -362,6 +443,12 @@ class Pane: focused_at: int = 0 +@dataclass(frozen=True) +class ViewportSplit: + split: int + width: float + + @dataclass(frozen=True) class Screen: id: int @@ -370,6 +457,8 @@ class Screen: active_pane: int layout: Layout panes: List[Pane] + viewport_base_width: Optional[float] = None + viewport_splits: List[ViewportSplit] = field(default_factory=list) @dataclass(frozen=True) @@ -797,6 +886,21 @@ def new_pane( self._require_protocol(9, "new-pane") return SurfaceResult(int(self._request("new-pane", pane=pane, cols=cols, rows=rows)["surface"])) + def new_pane_right( + self, + pane: int, + width: Optional[float] = None, + cols: Optional[int] = None, + rows: Optional[int] = None, + ) -> SurfaceResult: + if width is not None: + _validate_viewport_pane_width(width) + self._require_capability("viewport-splits-v1", "viewport panes") + data = self._request( + "new-pane-right", pane=pane, width=width, cols=cols, rows=rows + ) + return SurfaceResult(int(data["surface"])) + def split( self, pane: int, @@ -810,11 +914,42 @@ def set_ratio(self, pane: int, dir: str, ratio: float) -> EmptyResult: self._request("set-ratio", pane=pane, dir=dir, ratio=ratio) return EmptyResult() - def set_split_ratio(self, split: int, ratio: float) -> EmptyResult: + def set_split_ratio( + self, split: int, ratio: float, *, transaction: Optional[int] = None + ) -> EmptyResult: self._require_protocol(8, "set-split-ratio") - self._request("set-split-ratio", split=split, ratio=ratio) + params = {"split": split, "ratio": ratio} + if transaction is not None: + params["transaction"] = transaction + self._request("set-split-ratio", **params) return EmptyResult() + def set_viewport_pane_width( + self, pane: int, width: float, *, transaction: Optional[int] = None + ) -> EmptyResult: + _validate_viewport_pane_width(width) + self._require_capability( + "viewport-column-resize-v1", "viewport pane resizing" + ) + params = {"pane": pane, "width": width} + if transaction is not None: + params["transaction"] = transaction + self._request("set-viewport-pane-width", **params) + return EmptyResult() + + def undo_layout( + self, pane: int, confirmation_revision: Optional[int] = None + ) -> LayoutUndoResult: + """Preview layout undo, then pass the preview's exact revision to confirm.""" + self._require_capability("layout-undo-v1", "layout undo") + data = self._request( + "undo-layout", + pane=pane, + revision=confirmation_revision, + confirm_close=True if confirmation_revision is not None else None, + ) + return _decode_layout_undo_result(data) + def pane_neighbor(self, pane: int, dir: str) -> Dict[str, Any]: return self._request("pane-neighbor", pane=pane, dir=dir) @@ -1054,6 +1189,15 @@ def _parse_screen(value: Dict[str, Any]) -> Screen: active_pane=int(value.get("active_pane", 0)), layout=_parse_layout(value.get("layout", {"type": "leaf", "pane": 0})), panes=[_parse_pane(item) for item in value.get("panes", [])], + viewport_base_width=( + float(value["viewport_base_width"]) + if value.get("viewport_base_width") is not None + else None + ), + viewport_splits=[ + ViewportSplit(split=int(item["split"]), width=float(item["width"])) + for item in value.get("viewport_splits", []) + ], ) diff --git a/cmux-tui/bindings/python/e2e.py b/cmux-tui/bindings/python/e2e.py index 9b6aa809afba..214b2f9ee830 100755 --- a/cmux-tui/bindings/python/e2e.py +++ b/cmux-tui/bindings/python/e2e.py @@ -27,6 +27,14 @@ def main() -> int: assert marker in client.read_screen(created.surface).text workspace = find_workspace_for_surface(client.list_workspaces(), created.surface) assert workspace is not None + pane = find_pane_for_surface(client.list_workspaces(), created.surface) + assert pane is not None + client.new_pane_right(pane, width=0.5) + viewport_screen = find_screen_for_surface(client.list_workspaces(), created.surface) + assert viewport_screen is not None + assert viewport_screen.viewport_base_width == 1.0 + assert len(viewport_screen.viewport_splits) == 1 + assert abs(viewport_screen.viewport_splits[0].width - 0.5) < 0.0001 client.rename_surface(created.surface, f"{marker}-renamed") events = client.subscribe() try: @@ -142,6 +150,24 @@ def find_workspace_for_surface(tree, surface: int) -> int | None: return None +def find_pane_for_surface(tree, surface: int) -> int | None: + for workspace in tree.workspaces: + for screen in workspace.screens: + for pane in screen.panes: + if any(tab.surface == surface for tab in pane.tabs): + return pane.id + return None + + +def find_screen_for_surface(tree, surface: int): + for workspace in tree.workspaces: + for screen in workspace.screens: + for pane in screen.panes: + if any(tab.surface == surface for tab in pane.tabs): + return screen + return None + + def find_client_surface_size(client: CmuxClient, surface: int): for info in client.list_clients(): for size in info.sizes: diff --git a/cmux-tui/bindings/python/pyproject.toml b/cmux-tui/bindings/python/pyproject.toml index 0c7252862d56..9272e2208cc8 100644 --- a/cmux-tui/bindings/python/pyproject.toml +++ b/cmux-tui/bindings/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cmux" -version = "0.3.0" +version = "0.4.0" requires-python = ">=3.9" dependencies = [] diff --git a/cmux-tui/bindings/python/tests/test_protocol.py b/cmux-tui/bindings/python/tests/test_protocol.py index 63212a7994c4..66eed99a894b 100644 --- a/cmux-tui/bindings/python/tests/test_protocol.py +++ b/cmux-tui/bindings/python/tests/test_protocol.py @@ -4,14 +4,29 @@ from cmux import ( TERMINAL_KEY_TEXT_MAX_BYTES, CmuxClient, + CommandError, ProtocolError, TerminalKeyInput, TerminalModifiers, ) -from cmux.client import IdentifyResult, Layout, _parse_tree +from cmux.client import ( + IdentifyResult, + Layout, + LayoutUndoConfirmationRequired, + LayoutUndoUndone, + _parse_tree, +) class ProtocolTests(unittest.TestCase): + def test_command_error_preserves_machine_readable_code(self) -> None: + error = CommandError( + "layout changed", + {"ok": False, "error": "layout changed", "error_code": "layout-undo-stale"}, + ) + + self.assertEqual(error.error_code, "layout-undo-stale") + def test_identify_result_preserves_positional_artifact_revisions(self) -> None: result = IdentifyResult( "cmux-tui", "0.1.2", 7, "main", 42, "cmux-sha", "ghostty-sha" @@ -152,6 +167,30 @@ def test_workspace_registry_fields_and_placements(self) -> None: self.assertEqual(client.rename_workspace_registry("two", key="stable").workspace_revision, 6) self.assertEqual(client.move_workspace_registry(0, key="stable").workspace_revision, 6) + def test_screen_preserves_viewport_metadata(self) -> None: + tree = _parse_tree({ + "workspaces": [{ + "id": 1, + "name": "one", + "active": True, + "screens": [{ + "id": 2, + "name": None, + "active": True, + "active_pane": 3, + "layout": {"type": "leaf", "pane": 3}, + "viewport_base_width": 0.75, + "viewport_splits": [{"split": 4, "width": 0.5}], + "panes": [], + }], + }], + }) + + screen = tree.workspaces[0].screens[0] + self.assertEqual(screen.viewport_base_width, 0.75) + self.assertEqual(screen.viewport_splits[0].split, 4) + self.assertEqual(screen.viewport_splits[0].width, 0.5) + def test_workspace_registry_selectors_reject_missing_and_empty_keys_locally(self) -> None: client = CmuxClient.__new__(CmuxClient) @@ -278,6 +317,178 @@ def test_set_split_ratio_accepts_newer_additive_protocols(self) -> None: self.assertEqual(requests, [("set-split-ratio", {"split": 1, "ratio": 0.5})]) + def test_resize_methods_forward_explicit_transactions(self) -> None: + client = CmuxClient.__new__(CmuxClient) + client._protocol = 10 + client._capabilities = {"viewport-column-resize-v1"} + requests = [] + client._request = lambda command, **params: requests.append((command, params)) or {} + + client.set_split_ratio(1, 0.5, transaction=17) + client.set_viewport_pane_width(2, 0.75, transaction=17) + + self.assertEqual( + requests, + [ + ("set-split-ratio", {"split": 1, "ratio": 0.5, "transaction": 17}), + ( + "set-viewport-pane-width", + {"pane": 2, "width": 0.75, "transaction": 17}, + ), + ], + ) + + def test_viewport_widths_reject_invalid_values_locally(self) -> None: + client = CmuxClient.__new__(CmuxClient) + client._protocol = 10 + client._capabilities = { + "viewport-splits-v1", + "viewport-column-resize-v1", + } + requests = [] + client._request = lambda command, **params: ( + requests.append((command, params)) or {"surface": 9} + ) + + calls = ( + lambda width: client.new_pane_right(7, width=width), + lambda width: client.set_viewport_pane_width(7, width), + ) + invalid_widths = ( + float("nan"), + float("inf"), + float("-inf"), + 0.09, + 1.01, + ) + for call in calls: + for width in invalid_widths: + with self.subTest(call=call, width=width): + with self.assertRaisesRegex( + ValueError, + "viewport pane width must be between 0.1 and 1.0", + ): + call(width) + + self.assertEqual(requests, []) + + def test_layout_undo_preserves_preview_revision_for_confirmation(self) -> None: + client = CmuxClient.__new__(CmuxClient) + client._protocol = 10 + client._capabilities = {"layout-undo-v1"} + requests = [] + responses = [ + { + "undone": False, + "confirmation_required": True, + "screen": 3, + "revision": 8, + "closes_panes": [15], + }, + {"undone": True, "screen": 3, "revision": 9}, + ] + client._request = lambda command, **params: ( + requests.append((command, params)) or responses.pop(0) + ) + + preview = client.undo_layout(15) + self.assertEqual( + preview, + LayoutUndoConfirmationRequired( + screen=3, revision=8, closes_panes=[15] + ), + ) + result = client.undo_layout(15, preview.revision) + self.assertEqual(result, LayoutUndoUndone(screen=3, revision=9)) + self.assertEqual( + requests, + [ + ( + "undo-layout", + { + "pane": 15, + "revision": None, + "confirm_close": None, + }, + ), + ( + "undo-layout", + { + "pane": 15, + "revision": 8, + "confirm_close": True, + }, + ), + ], + ) + + def test_layout_undo_rejects_malformed_results(self) -> None: + client = CmuxClient.__new__(CmuxClient) + client._protocol = 10 + client._capabilities = {"layout-undo-v1"} + responses = ( + [], + { + "undone": False, + "confirmation_required": True, + "screen": 3, + "revision": 8, + }, + { + "undone": False, + "confirmation_required": True, + "screen": 3, + "revision": 8, + "closes_panes": [True], + }, + { + "undone": False, + "confirmation_required": True, + "screen": 3, + "revision": 8, + "closes_panes": [-1], + }, + { + "confirmation_required": True, + "screen": 3, + "revision": 8, + "closes_panes": [15], + }, + { + "undone": True, + "confirmation_required": True, + "screen": 3, + "revision": 8, + "closes_panes": [15], + }, + { + "undone": False, + "confirmation_required": True, + "screen": True, + "revision": 8, + "closes_panes": [15], + }, + { + "undone": False, + "confirmation_required": True, + "screen": 3, + "revision": -1, + "closes_panes": [15], + }, + { + "undone": True, + "confirmation_required": None, + "screen": 3, + "revision": 8, + }, + ) + + for response in responses: + with self.subTest(response=response): + client._request = lambda command, **params: response + with self.assertRaises(ProtocolError): + client.undo_layout(15) + def test_layout_preserves_protocol_seven_positional_constructor_order(self) -> None: first = Layout("leaf", 1) second = Layout("leaf", 2) diff --git a/cmux-tui/bindings/rust/Cargo.toml b/cmux-tui/bindings/rust/Cargo.toml index 33cc421c45ec..06f14f995ac1 100644 --- a/cmux-tui/bindings/rust/Cargo.toml +++ b/cmux-tui/bindings/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cmux-client" -version = "0.3.0" +version = "0.4.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/cmux-tui/bindings/rust/README.md b/cmux-tui/bindings/rust/README.md index 3d290c35ae73..27329e7d73a1 100644 --- a/cmux-tui/bindings/rust/README.md +++ b/cmux-tui/bindings/rust/README.md @@ -24,6 +24,12 @@ println!("{}", client.read_screen(surface)?.text); `ClientConfig::default()` uses `CMUX_TUI_SOCKET` when set, then legacy `CMUX_MUX_SOCKET`, then the default session socket path. +## 0.4 migration + +`CmuxError::Command` now includes `error_code: Option`. Update exhaustive +patterns and constructors to include `error_code`, or use `..` when that field +is not needed. + ## 0.3 migration `identify()` keeps the legacy `IdentifyResult` shape. Use `identify_details()` diff --git a/cmux-tui/bindings/rust/examples/e2e.rs b/cmux-tui/bindings/rust/examples/e2e.rs index 707cd782e9ad..8a5514008490 100644 --- a/cmux-tui/bindings/rust/examples/e2e.rs +++ b/cmux-tui/bindings/rust/examples/e2e.rs @@ -21,8 +21,17 @@ fn main() -> Result<()> { let screen = client.read_screen(created.surface)?; assert!(screen.text.contains(&marker), "marker missing from read-screen"); - let workspace_id = find_workspace_for_surface(&client.list_workspaces()?, created.surface) - .expect("workspace not found"); + let tree = client.list_workspaces()?; + let workspace_id = + find_workspace_for_surface(&tree, created.surface).expect("workspace not found"); + let pane_id = find_pane_for_surface(&tree, created.surface).expect("pane not found"); + client.new_pane_right(pane_id, Some(0.5), None, None)?; + let viewport_tree = client.list_workspaces()?; + let viewport_screen = find_screen_for_surface(&viewport_tree, created.surface) + .expect("viewport screen not found"); + assert_eq!(viewport_screen.viewport_base_width, Some(1.0)); + assert_eq!(viewport_screen.viewport_splits.len(), 1); + assert!((viewport_screen.viewport_splits[0].width - 0.5).abs() < 0.0001); client.rename_surface(created.surface, &format!("{marker}-renamed"))?; let mut events = client.subscribe()?; client.resize_surface(created.surface, 100, 31)?; @@ -125,6 +134,30 @@ fn find_workspace_for_surface(tree: &Tree, surface: u64) -> Option { None } +fn find_pane_for_surface(tree: &Tree, surface: u64) -> Option { + for workspace in &tree.workspaces { + for screen in &workspace.screens { + for pane in &screen.panes { + if pane.tabs.iter().any(|tab| tab.surface == surface) { + return Some(pane.id); + } + } + } + } + None +} + +fn find_screen_for_surface(tree: &Tree, surface: u64) -> Option<&cmux_client::Screen> { + for workspace in &tree.workspaces { + for screen in &workspace.screens { + if screen.panes.iter().any(|pane| pane.tabs.iter().any(|tab| tab.surface == surface)) { + return Some(screen); + } + } + } + None +} + fn find_client_surface_size( client: &mut CmuxClient, surface: u64, diff --git a/cmux-tui/bindings/rust/src/lib.rs b/cmux-tui/bindings/rust/src/lib.rs index 18f0e82350f6..c2cadded5852 100644 --- a/cmux-tui/bindings/rust/src/lib.rs +++ b/cmux-tui/bindings/rust/src/lib.rs @@ -11,10 +11,17 @@ use std::time::Duration; pub type Result = std::result::Result; pub const TERMINAL_KEY_TEXT_MAX_BYTES: usize = 4 * 1024; +const MIN_VIEWPORT_PANE_WIDTH: f32 = 0.1; +const MAX_VIEWPORT_PANE_WIDTH: f32 = 1.0; #[derive(Debug)] pub enum CmuxError { - Command { message: String, id: Option, delivery: Option }, + Command { + message: String, + id: Option, + error_code: Option, + delivery: Option, + }, Decode(String), Connection(String), Timeout(String), @@ -51,6 +58,19 @@ fn error_delivery(response: &Value) -> Option { } } +fn command_error(response: &Value) -> CmuxError { + CmuxError::Command { + message: response + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown error") + .to_string(), + id: response.get("id").cloned(), + error_code: response.get("error_code").and_then(Value::as_str).map(ToString::to_string), + delivery: error_delivery(response), + } +} + #[derive(Debug, Clone)] pub struct ClientConfig { pub socket_path: PathBuf, @@ -359,6 +379,47 @@ pub struct SurfaceResult { pub surface: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LayoutUndoResult { + Undone { screen: u64, revision: u64 }, + ConfirmationRequired { screen: u64, revision: u64, closes_panes: Vec }, +} + +impl<'de> Deserialize<'de> for LayoutUndoResult { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + undone: Option, + confirmation_required: Option, + screen: u64, + revision: u64, + closes_panes: Option>, + } + + let wire = Wire::deserialize(deserializer)?; + match (wire.undone, wire.confirmation_required) { + (Some(true), None | Some(false)) => { + Ok(Self::Undone { screen: wire.screen, revision: wire.revision }) + } + (Some(false), Some(true)) => Ok(Self::ConfirmationRequired { + screen: wire.screen, + revision: wire.revision, + closes_panes: wire.closes_panes.ok_or_else(|| { + serde::de::Error::custom( + "layout undo confirmation closes_panes must be an array of pane IDs", + ) + })?, + }), + _ => Err(serde::de::Error::custom( + "layout undo response does not contain exactly one valid outcome", + )), + } + } +} + #[derive(Debug, Clone, Deserialize)] pub struct WorkspacePlacement { pub workspace: u64, @@ -419,6 +480,15 @@ fn validate_workspace_selector(workspace: Option, key: Option<&str>) -> Res Ok(()) } +fn validate_viewport_pane_width(width: f32) -> Result<()> { + if !width.is_finite() || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) { + return Err(CmuxError::InvalidArgument( + "viewport pane width must be between 0.1 and 1.0".to_string(), + )); + } + Ok(()) +} + #[derive(Debug, Clone, Copy, Default)] pub struct AttachSurfaceOptions { pub cols: Option, @@ -456,6 +526,12 @@ pub struct Workspace { pub screens: Vec, } +#[derive(Debug, Clone, Deserialize)] +pub struct ViewportSplit { + pub split: u64, + pub width: f32, +} + #[derive(Debug, Clone, Deserialize)] pub struct Screen { pub id: u64, @@ -463,6 +539,10 @@ pub struct Screen { pub active: bool, pub active_pane: u64, pub layout: Layout, + #[serde(default)] + pub viewport_base_width: Option, + #[serde(default)] + pub viewport_splits: Vec, pub panes: Vec, } @@ -661,15 +741,7 @@ impl CmuxClient { let data = response.get("data").cloned().unwrap_or(Value::Object(Map::new())); serde_json::from_value(data).map_err(|err| CmuxError::Decode(err.to_string())) } else { - Err(CmuxError::Command { - message: response - .get("error") - .and_then(Value::as_str) - .unwrap_or("unknown error") - .to_string(), - id: response.get("id").cloned(), - delivery: error_delivery(&response), - }) + Err(command_error(&response)) } } @@ -868,6 +940,25 @@ impl CmuxClient { self.request("new-pane", params) } + pub fn new_pane_right( + &mut self, + pane: u64, + width: Option, + cols: Option, + rows: Option, + ) -> Result { + if let Some(width) = width { + validate_viewport_pane_width(width)?; + } + self.require_capability("viewport-splits-v1", "viewport panes")?; + let mut params = Map::new(); + params.insert("pane".to_string(), Value::from(pane)); + insert_opt(&mut params, "width", width); + insert_opt(&mut params, "cols", cols); + insert_opt(&mut params, "rows", rows); + self.request("new-pane-right", params) + } + pub fn split( &mut self, pane: u64, @@ -892,13 +983,80 @@ impl CmuxClient { } pub fn set_split_ratio(&mut self, split: u64, ratio: f32) -> Result<()> { + self.set_split_ratio_request(split, ratio, None) + } + + pub fn set_split_ratio_in_transaction( + &mut self, + split: u64, + ratio: f32, + transaction: u64, + ) -> Result<()> { + self.set_split_ratio_request(split, ratio, Some(transaction)) + } + + fn set_split_ratio_request( + &mut self, + split: u64, + ratio: f32, + transaction: Option, + ) -> Result<()> { self.require_protocol(8, "set-split-ratio")?; let mut params = Map::new(); params.insert("split".to_string(), Value::from(split)); params.insert("ratio".to_string(), Value::from(ratio)); + insert_opt(&mut params, "transaction", transaction); self.request::("set-split-ratio", params).map(|_| ()) } + pub fn set_viewport_pane_width(&mut self, pane: u64, width: f32) -> Result<()> { + self.set_viewport_pane_width_request(pane, width, None) + } + + pub fn set_viewport_pane_width_in_transaction( + &mut self, + pane: u64, + width: f32, + transaction: u64, + ) -> Result<()> { + self.set_viewport_pane_width_request(pane, width, Some(transaction)) + } + + fn set_viewport_pane_width_request( + &mut self, + pane: u64, + width: f32, + transaction: Option, + ) -> Result<()> { + validate_viewport_pane_width(width)?; + self.require_capability("viewport-column-resize-v1", "viewport pane resizing")?; + let mut params = Map::new(); + params.insert("pane".to_string(), Value::from(pane)); + params.insert("width".to_string(), Value::from(width)); + insert_opt(&mut params, "transaction", transaction); + self.request::("set-viewport-pane-width", params).map(|_| ()) + } + + /// Undo the latest structural layout action. + /// + /// Pass `None` to preview the undo. If the result is + /// `ConfirmationRequired`, show its closing pane ids and call this method + /// again with that exact `revision`. + pub fn undo_layout( + &mut self, + pane: u64, + confirmation_revision: Option, + ) -> Result { + self.require_capability("layout-undo-v1", "layout undo")?; + let mut params = Map::new(); + params.insert("pane".to_string(), Value::from(pane)); + if let Some(revision) = confirmation_revision { + params.insert("revision".to_string(), Value::from(revision)); + params.insert("confirm_close".to_string(), Value::Bool(true)); + } + self.request("undo-layout", params) + } + pub fn set_default_colors(&mut self, fg: Option<&str>, bg: Option<&str>) -> Result<()> { let mut params = Map::new(); insert_opt(&mut params, "fg", fg); @@ -1168,15 +1326,7 @@ impl CmuxStream { if response.get("ok") == Some(&Value::Bool(true)) { return Ok(Self { conn, buffered, finished: false }); } - return Err(CmuxError::Command { - message: response - .get("error") - .and_then(Value::as_str) - .unwrap_or("unknown error") - .to_string(), - id: response.get("id").cloned(), - delivery: error_delivery(&response), - }); + return Err(command_error(&response)); } } @@ -1380,6 +1530,43 @@ mod tests { assert_eq!(details.ghostty_commit.as_deref(), Some("ghostty-sha")); } + #[test] + fn command_error_shape_change_uses_a_breaking_sdk_version() { + let mut components = env!("CARGO_PKG_VERSION").split('.'); + let major = components.next().unwrap().parse::().unwrap(); + let minor = components.next().unwrap().parse::().unwrap(); + assert!( + major > 0 || minor >= 4, + "the public CmuxError::Command shape requires cmux-client 0.4 or newer" + ); + } + + #[test] + fn published_sdk_versions_stay_synchronized() { + let rust_version = env!("CARGO_PKG_VERSION"); + let typescript: Value = + serde_json::from_str(include_str!("../../typescript/package.json")).unwrap(); + let typescript_lock: Value = + serde_json::from_str(include_str!("../../typescript/package-lock.json")).unwrap(); + let python_manifest = include_str!("../../python/pyproject.toml"); + let python_version = python_manifest + .lines() + .find_map(|line| line.strip_prefix("version = \"")?.strip_suffix('"')) + .unwrap(); + let release_runbook = include_str!("../../RELEASING.md"); + let documented_version = release_runbook + .split_once("Current SDK package versions are") + .and_then(|(_, suffix)| suffix.trim_start().strip_prefix('`')) + .and_then(|suffix| suffix.split_once('`').map(|(version, _)| version)) + .unwrap(); + + assert_eq!(typescript["version"].as_str(), Some(rust_version)); + assert_eq!(typescript_lock["version"].as_str(), Some(rust_version)); + assert_eq!(typescript_lock["packages"][""]["version"].as_str(), Some(rust_version)); + assert_eq!(python_version, rust_version); + assert_eq!(documented_version, rust_version); + } + #[test] fn default_socket_path_preserves_compatible_runtime_dir() { let runtime_dir = PathBuf::from("/tmp/cmux-tui-compat"); @@ -1541,6 +1728,64 @@ mod tests { assert_eq!(tree.pane_revision, Some(7)); } + #[test] + fn screen_preserves_viewport_metadata() { + let tree: Tree = serde_json::from_value(serde_json::json!({ + "workspaces": [{ + "id": 1, + "name": "one", + "active": true, + "screens": [{ + "id": 2, + "name": null, + "active": true, + "active_pane": 3, + "layout": {"type": "leaf", "pane": 3}, + "viewport_base_width": 0.75, + "viewport_splits": [{"split": 4, "width": 0.5}], + "panes": [], + }], + }], + })) + .unwrap(); + + let screen = &tree.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, Some(0.75)); + assert_eq!(screen.viewport_splits.len(), 1); + assert_eq!(screen.viewport_splits[0].split, 4); + assert_eq!(screen.viewport_splits[0].width, 0.5); + } + + #[test] + fn command_errors_preserve_machine_readable_codes() { + let (socket, mut peer) = UnixStream::pair().unwrap(); + let writer = socket.try_clone().unwrap(); + let mut client = CmuxClient { + config: ClientConfig::default(), + conn: JsonLineConnection { writer, reader: BufReader::new(socket) }, + next_id: 1, + protocol: None, + capabilities: Vec::new(), + }; + let server = std::thread::spawn(move || { + let mut request = String::new(); + BufReader::new(peer.try_clone().unwrap()).read_line(&mut request).unwrap(); + peer.write_all( + br#"{"id":1,"ok":false,"error":"layout changed","error_code":"layout-undo-stale"} +"#, + ) + .unwrap(); + }); + + let error = client.request::("undo-layout", Map::new()).unwrap_err(); + server.join().unwrap(); + + assert!(matches!( + error, + CmuxError::Command { error_code: Some(code), .. } if code == "layout-undo-stale" + )); + } + #[test] fn workspace_registry_placements_decode() { let workspace: WorkspacePlacement = serde_json::from_value(serde_json::json!({ @@ -1571,6 +1816,56 @@ mod tests { assert_eq!(mutation.workspace_revision, 5); } + #[test] + fn layout_undo_decodes_both_outcomes() { + let preview: LayoutUndoResult = serde_json::from_value(serde_json::json!({ + "undone": false, + "confirmation_required": true, + "screen": 3, + "revision": 8, + "closes_panes": [15], + })) + .unwrap(); + assert_eq!( + preview, + LayoutUndoResult::ConfirmationRequired { + screen: 3, + revision: 8, + closes_panes: vec![15], + } + ); + + let undone: LayoutUndoResult = serde_json::from_value(serde_json::json!({ + "undone": true, + "screen": 3, + "revision": 9, + })) + .unwrap(); + assert_eq!(undone, LayoutUndoResult::Undone { screen: 3, revision: 9 }); + } + + #[test] + fn layout_undo_rejects_confirmation_without_closes_panes() { + let result = serde_json::from_value::(serde_json::json!({ + "undone": false, + "confirmation_required": true, + "screen": 3, + "revision": 8, + })); + assert!(result.is_err()); + } + + #[test] + fn layout_undo_rejects_confirmation_without_undone_discriminator() { + let result = serde_json::from_value::(serde_json::json!({ + "confirmation_required": true, + "screen": 3, + "revision": 8, + "closes_panes": [15], + })); + assert!(result.is_err()); + } + #[test] fn workspace_registry_selectors_reject_missing_and_empty_keys_locally() { assert!(matches!( @@ -1845,6 +2140,43 @@ mod tests { client.require_protocol(8, "set-split-ratio").unwrap(); } + #[test] + fn resize_methods_forward_explicit_transactions() { + let (socket, mut peer) = UnixStream::pair().unwrap(); + let writer = socket.try_clone().unwrap(); + let mut client = CmuxClient { + config: ClientConfig::default(), + conn: JsonLineConnection { writer, reader: BufReader::new(socket) }, + next_id: 1, + protocol: Some(10), + capabilities: vec!["viewport-column-resize-v1".to_string()], + }; + let peer_reader = peer.try_clone().unwrap(); + let capture = std::thread::spawn(move || { + let mut reader = BufReader::new(peer_reader); + let mut requests = Vec::new(); + for _ in 0..2 { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + let id = request["id"].clone(); + requests.push(request); + writeln!(peer, "{}", serde_json::json!({"id": id, "ok": true, "data": {}})) + .unwrap(); + } + requests + }); + + client.set_split_ratio_in_transaction(42, 0.6, 17).unwrap(); + client.set_viewport_pane_width_in_transaction(9, 0.75, 17).unwrap(); + + let requests = capture.join().unwrap(); + assert_eq!(requests[0]["cmd"], "set-split-ratio"); + assert_eq!(requests[0]["transaction"], 17); + assert_eq!(requests[1]["cmd"], "set-viewport-pane-width"); + assert_eq!(requests[1]["transaction"], 17); + } + #[test] fn new_pane_requires_protocol_nine() { let (socket, _peer) = UnixStream::pair().unwrap(); @@ -1860,6 +2192,57 @@ mod tests { assert_eq!(error.to_string(), "new-pane requires protocol 9; server uses protocol 8"); } + #[test] + fn new_pane_right_rejects_invalid_widths_without_sending_a_command() { + let (socket, mut peer) = UnixStream::pair().unwrap(); + let writer = socket.try_clone().unwrap(); + let mut client = CmuxClient { + config: ClientConfig::default(), + conn: JsonLineConnection { writer, reader: BufReader::new(socket) }, + next_id: 1, + protocol: Some(10), + capabilities: vec!["viewport-splits-v1".to_string()], + }; + let peer_reader = peer.try_clone().unwrap(); + let capture = std::thread::spawn(move || { + let mut reader = BufReader::new(peer_reader); + let mut requests = Vec::new(); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + let request: Value = serde_json::from_str(&line).unwrap(); + let id = request["id"].clone(); + requests.push(request); + writeln!( + peer, + "{}", + serde_json::json!({ + "id": id, + "ok": true, + "data": {"surface": 9}, + }) + ) + .unwrap(); + } + Err(error) => panic!("failed to capture request: {error}"), + } + } + requests + }); + + for width in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.09, 1.01] { + assert!(matches!( + client.new_pane_right(7, Some(width), None, None), + Err(CmuxError::InvalidArgument(message)) + if message == "viewport pane width must be between 0.1 and 1.0" + )); + } + drop(client); + assert!(capture.join().unwrap().is_empty()); + } + #[test] fn overflow_decodes_recovery_fields() { let event = parse_event(serde_json::json!({ diff --git a/cmux-tui/bindings/typescript/e2e/e2e.ts b/cmux-tui/bindings/typescript/e2e/e2e.ts index 28e8d6425a63..3eec5fc49912 100644 --- a/cmux-tui/bindings/typescript/e2e/e2e.ts +++ b/cmux-tui/bindings/typescript/e2e/e2e.ts @@ -24,6 +24,16 @@ async function main(): Promise { const paneId = findPaneForSurface(tree, created.surface); assert(paneId !== undefined, "new pane not found"); + await client.newPaneRight(paneId, { width: 0.5 }); + const viewportScreen = findScreenForSurface(await client.listWorkspaces(), created.surface); + assert(viewportScreen !== undefined, "viewport screen not found"); + assert(viewportScreen.viewport_base_width === 1, "viewport base width missing"); + assert(viewportScreen.viewport_splits?.length === 1, "viewport split metadata missing"); + assert( + Math.abs(viewportScreen.viewport_splits[0].width - 0.5) < 0.0001, + "viewport split width did not round-trip", + ); + await client.split(paneId, "right"); const splitTree = await client.listWorkspaces(); const layout = findLayoutForSurface(splitTree, created.surface); @@ -171,7 +181,18 @@ function findLayoutForSurface(tree: Tree, surface: number) { } } } - return undefined; + return undefined; +} + +function findScreenForSurface(tree: Tree, surface: number) { + for (const workspace of tree.workspaces) { + for (const screen of workspace.screens) { + if (screen.panes.some((pane) => "tabs" in pane && pane.tabs.some((tab) => tab.surface === surface))) { + return screen; + } + } + } + return undefined; } function findClientSurfaceSize( diff --git a/cmux-tui/bindings/typescript/package-lock.json b/cmux-tui/bindings/typescript/package-lock.json index 80c5d2ff0e9f..3eef6ea75b6a 100644 --- a/cmux-tui/bindings/typescript/package-lock.json +++ b/cmux-tui/bindings/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "cmux", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cmux", - "version": "0.3.0", + "version": "0.4.0", "devDependencies": { "@types/node": "20.19.25", "typescript": "5.9.3" diff --git a/cmux-tui/bindings/typescript/package.json b/cmux-tui/bindings/typescript/package.json index 637f725d1de1..58c2113ea49d 100644 --- a/cmux-tui/bindings/typescript/package.json +++ b/cmux-tui/bindings/typescript/package.json @@ -1,6 +1,6 @@ { "name": "cmux", - "version": "0.3.0", + "version": "0.4.0", "description": "Typed TypeScript client for cmux-tui frontends", "repository": { "type": "git", diff --git a/cmux-tui/bindings/typescript/src/browser.ts b/cmux-tui/bindings/typescript/src/browser.ts index b931b649ee9c..d81b8cd8f12c 100644 --- a/cmux-tui/bindings/typescript/src/browser.ts +++ b/cmux-tui/bindings/typescript/src/browser.ts @@ -6,9 +6,11 @@ export { type CmuxClientOptions as ClientOptions, type AttachSurfaceOptions, type NewBrowserTabOptions, + type NewPaneRightOptions, type NewScreenOptions, type NewTabOptions, type NewWorkspaceOptions, + type ResizeTransactionOptions, type SelectOptions, type SelectTabOptions, type SendOptions, diff --git a/cmux-tui/bindings/typescript/src/client.ts b/cmux-tui/bindings/typescript/src/client.ts index 8b0abc950bf3..fd37f361eac0 100644 --- a/cmux-tui/bindings/typescript/src/client.ts +++ b/cmux-tui/bindings/typescript/src/client.ts @@ -18,6 +18,7 @@ import type { ColorHex, CopyMode, CopyResult, + DecodedBrowserFrame, DecodedAttachEvent, EmptyResult, ExportLayoutResult, @@ -31,6 +32,7 @@ import type { ListAgentsResult, ListClientsResult, ListTerminalsResult, + LayoutUndoResult, MoveTerminalResult, NotificationLevel, NotifyResult, @@ -84,6 +86,64 @@ export const DEFAULT_MAX_BUFFERED_EVENTS = 256; export const DEFAULT_MAX_ATTACH_ENCODED_CHARS = 16 * 1024 * 1024; export const TERMINAL_KEY_TEXT_MAX_BYTES = 4 * 1024; +function validateViewportPaneWidth(width: unknown): asserts width is number { + if ( + typeof width !== "number" + || !Number.isFinite(width) + || width < 0.1 + || width > 1.0 + ) { + throw new CmuxProtocolError("viewport pane width must be between 0.1 and 1.0"); + } +} + +function layoutUndoUint(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new CmuxProtocolError(`layout undo ${field} is not a nonnegative integer`); + } + return value; +} + +function decodeLayoutUndoResult(value: unknown): LayoutUndoResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new CmuxProtocolError("layout undo result is not an object"); + } + const result = value as Record; + const screen = layoutUndoUint(result.screen, "screen"); + const revision = layoutUndoUint(result.revision, "revision"); + + if ( + result.undone === true + && (result.confirmation_required === undefined + || result.confirmation_required === false) + ) { + return { undone: true, screen, revision }; + } + if (result.undone === false && result.confirmation_required === true) { + if (!Array.isArray(result.closes_panes)) { + throw new CmuxProtocolError("layout undo closes_panes is not an array"); + } + return { + undone: false, + confirmation_required: true, + screen, + revision, + closes_panes: result.closes_panes.map((pane) => layoutUndoUint(pane, "pane ID")), + }; + } + throw new CmuxProtocolError("layout undo result does not contain exactly one valid outcome"); +} + +function decodeResponseData(command: CmuxCommand, value: unknown): unknown { + if (command === "undo-layout") return decodeLayoutUndoResult(value); + return value; +} + +export interface ResizeTransactionOptions { + /** Reuse across one continuous drag, then choose a new value for the next drag. */ + transaction?: number | null; +} + function workspaceMutationResult(result: EmptyResult | WorkspaceMutation): WorkspaceMutation { if ("workspace" in result && "key" in result @@ -129,6 +189,7 @@ export type RenameWorkspaceOptions = CmuxRequestParams<"rename-workspace">; export type MoveWorkspaceOptions = CmuxRequestParams<"move-workspace">; export type NewScreenOptions = CmuxRequestParams<"new-screen">; export type NewPaneOptions = Omit, "pane">; +export type NewPaneRightOptions = Omit, "pane">; export type SplitOptions = Omit, "pane" | "dir">; export type SelectOptions = CmuxRequestParams<"select-screen">; export type SelectTabOptions = CmuxRequestParams<"select-tab">; @@ -441,11 +502,14 @@ export class CmuxClient { ? { cmd: requestOrCommand, ...(params ?? {}) } : requestOrCommand; const response = await this.sendRaw(request as unknown as JsonObject); - if (response.ok) return response.data as CmuxResponseDataFor; + if (response.ok) { + return decodeResponseData(request.cmd, response.data) as CmuxResponseDataFor; + } throw new CmuxCommandError( response.error || "unknown error", response.id, response, + response.error_code, response.error_delivery, ); } @@ -564,15 +628,55 @@ export class CmuxClient { await this.requireProtocol(9, "new-pane"); return this.request("new-pane", { pane, ...options }); } + async newPaneRight( + pane: Id, + options: NewPaneRightOptions = {}, + ): Promise { + if (options.width !== undefined && options.width !== null) { + validateViewportPaneWidth(options.width); + } + await this.requireCapability("viewport-splits-v1", "viewport panes"); + return this.request("new-pane-right", { pane, ...options }); + } split(pane: Id, dir: SplitDirection, options: SplitOptions = {}): Promise { return this.request("split", { pane, dir, ...options }); } setRatio(pane: Id, dir: SplitDirection, ratio: number): Promise { return this.request("set-ratio", { pane, dir, ratio }); } - async setSplitRatio(split: Id, ratio: number): Promise { + async setSplitRatio( + split: Id, + ratio: number, + options: ResizeTransactionOptions = {}, + ): Promise { await this.requireProtocol(8, "set-split-ratio"); - return this.request("set-split-ratio", { split, ratio }); + return this.request("set-split-ratio", { split, ratio, ...options }); + } + async setViewportPaneWidth( + pane: Id, + width: number, + options: ResizeTransactionOptions = {}, + ): Promise { + validateViewportPaneWidth(width); + await this.requireCapability( + "viewport-column-resize-v1", + "viewport pane resizing", + ); + return this.request("set-viewport-pane-width", { pane, width, ...options }); + } + async undoLayout( + pane: Id, + confirmationRevision?: number, + ): Promise { + await this.requireCapability("layout-undo-v1", "layout undo"); + if (confirmationRevision === undefined) { + return this.request("undo-layout", { pane }); + } + return this.request("undo-layout", { + pane, + revision: confirmationRevision, + confirm_close: true, + }); } paneNeighbor(pane: Id, dir: PaneDirection): Promise { return this.request("pane-neighbor", { pane, dir }); @@ -796,6 +900,7 @@ export class CmuxClient { response.error || "unknown error", response.id, response, + response.error_code, response.error_delivery, ); } @@ -818,19 +923,18 @@ export class CmuxClient { return { ...event, data, replay: data } as DecodedAttachEvent; } case "frame": { - this.validateAttachEncodedData(event.data, "frame"); - return event as DecodedAttachEvent; + return { + ...event, + ...this.decodeBrowserFrame(event, "frame"), + } as DecodedAttachEvent; } case "browser-state": { const frame = event.frame; if (frame !== undefined && frame !== null) { - if (typeof frame !== "object" || Array.isArray(frame)) { - throw new CmuxProtocolError("browser-state frame is not an object"); - } - this.validateAttachEncodedData( - (frame as { data?: unknown }).data, - "browser-state frame", - ); + return { + ...event, + frame: this.decodeBrowserFrame(frame, "browser-state frame"), + } as DecodedAttachEvent; } return event as DecodedAttachEvent; } @@ -842,6 +946,61 @@ export class CmuxClient { return decodeBase64(this.validateAttachEncodedData(value, eventName)); } + private decodeBrowserFrame(value: unknown, eventName: string): DecodedBrowserFrame { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new CmuxProtocolError(`${eventName} is not an object`); + } + const frame = value as Record; + const seq = this.validateFrameSequence(frame.seq, eventName); + const width = this.validateFrameCssDimension(frame.width, eventName, "width"); + const height = this.validateFrameCssDimension(frame.height, eventName, "height"); + const imageWidth = frame.image_width === undefined + ? width + : this.validateFrameDimension(frame.image_width, eventName, "image_width"); + const imageHeight = frame.image_height === undefined + ? height + : this.validateFrameDimension(frame.image_height, eventName, "image_height"); + const data = this.validateAttachEncodedData(frame.data, eventName); + return { + ...frame, + seq, + width, + height, + image_width: imageWidth, + image_height: imageHeight, + data, + } as DecodedBrowserFrame; + } + + private validateFrameSequence(value: unknown, eventName: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new CmuxProtocolError(`${eventName} seq is not a nonnegative integer`); + } + return value; + } + + private validateFrameCssDimension( + value: unknown, + eventName: string, + field: string, + ): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new CmuxProtocolError(`${eventName} ${field} is not a nonnegative integer`); + } + return value; + } + + private validateFrameDimension( + value: unknown, + eventName: string, + field: string, + ): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new CmuxProtocolError(`${eventName} ${field} is not a positive integer`); + } + return value; + } + private validateAttachEncodedData(value: unknown, eventName: string): string { if (typeof value !== "string") { throw new CmuxProtocolError(`${eventName} data is not base64 text`); diff --git a/cmux-tui/bindings/typescript/src/errors.ts b/cmux-tui/bindings/typescript/src/errors.ts index e271ba492457..d71db23625f7 100644 --- a/cmux-tui/bindings/typescript/src/errors.ts +++ b/cmux-tui/bindings/typescript/src/errors.ts @@ -10,6 +10,7 @@ export class CmuxError extends Error { export class CmuxCommandError extends CmuxError { readonly commandId: unknown; readonly response: unknown; + readonly errorCode: string | undefined; readonly delivery: CmuxErrorDelivery | undefined; constructor( @@ -17,11 +18,30 @@ export class CmuxCommandError extends CmuxError { commandId?: unknown, response?: unknown, delivery?: CmuxErrorDelivery, + ); + constructor( + message: string, + commandId?: unknown, + response?: unknown, + errorCode?: string, + delivery?: CmuxErrorDelivery, + ); + constructor( + message: string, + commandId?: unknown, + response?: unknown, + errorCode?: string, + delivery?: CmuxErrorDelivery, ) { super(message); this.commandId = commandId; this.response = response; - this.delivery = delivery; + const legacyDelivery = delivery === undefined + && (errorCode === "known-not-delivered" || errorCode === "ambiguous") + ? errorCode + : undefined; + this.errorCode = legacyDelivery === undefined ? errorCode : undefined; + this.delivery = delivery ?? legacyDelivery; } } diff --git a/cmux-tui/bindings/typescript/src/index.ts b/cmux-tui/bindings/typescript/src/index.ts index c7d81f5dd552..c9a37ffbbab1 100644 --- a/cmux-tui/bindings/typescript/src/index.ts +++ b/cmux-tui/bindings/typescript/src/index.ts @@ -5,6 +5,7 @@ export { type AttachSurfaceOptions, type CmuxClientOptions, type NewBrowserTabOptions, + type NewPaneRightOptions, type NewScreenOptions, type NewTabOptions, type NewWorkspaceOptions, @@ -13,6 +14,7 @@ export { type CloseWorkspaceOptions, type MoveWorkspaceOptions, type RenameWorkspaceOptions, + type ResizeTransactionOptions, type SelectOptions, type SelectTabOptions, type SendOptions, diff --git a/cmux-tui/bindings/typescript/src/protocol/commands.ts b/cmux-tui/bindings/typescript/src/protocol/commands.ts index e81de911544e..2fdb82da51f2 100644 --- a/cmux-tui/bindings/typescript/src/protocol/commands.ts +++ b/cmux-tui/bindings/typescript/src/protocol/commands.ts @@ -13,7 +13,7 @@ import type { PaneDirection, SplitDirection, } from "./common.js"; -import type { DeclarativeLayout, Layout, Tree } from "./tree.js"; +import type { DeclarativeLayout, Layout, Tree, ViewportSplit } from "./tree.js"; import type { RenderRow } from "./render.js"; export interface IdentifyRequest extends CmuxRequestBase { cmd: "identify" } @@ -128,7 +128,14 @@ export interface ExportLayoutRequest extends CmuxRequestBase { screen?: Id | null; } export interface ExportedPane { pane: Id; surfaces: Id[] } -export interface ExportLayoutResult { layout: Layout; panes: ExportedPane[] } +export interface ExportLayoutResult { + layout: Layout; + panes: ExportedPane[]; + /** Authoritative width of the first viewport column. Older servers omit it. */ + viewport_base_width?: number; + /** Authoritative widths of appended viewport columns. Older servers omit them. */ + viewport_splits?: ViewportSplit[]; +} export interface ApplyLayoutRequest extends CmuxRequestBase { cmd: "apply-layout"; @@ -447,6 +454,14 @@ export interface NewPaneRequest extends CmuxRequestBase { rows?: number | null; } +export interface NewPaneRightRequest extends CmuxRequestBase { + cmd: "new-pane-right"; + pane: Id; + width?: number | null; + cols?: number | null; + rows?: number | null; +} + export interface SplitRequest extends CmuxRequestBase { cmd: "split"; pane: Id; @@ -475,7 +490,40 @@ export interface SetSplitRatioRequest extends CmuxRequestBase { split: Id; /** The server clamps this value to `0.05..0.95`. */ ratio: number; + /** Samples sharing one client-scoped transaction form one undo entry. */ + transaction?: number | null; +} + +export interface SetViewportPaneWidthRequest extends CmuxRequestBase { + cmd: "set-viewport-pane-width"; + pane: Id; + width: number; + /** Samples sharing one client-scoped transaction form one undo entry. */ + transaction?: number | null; +} + +interface UndoLayoutRequestBase extends CmuxRequestBase { + cmd: "undo-layout"; + pane: Id; } +export type UndoLayoutRequest = + | (UndoLayoutRequestBase & { revision?: null; confirm_close?: false | null }) + | (UndoLayoutRequestBase & { revision: number; confirm_close: true }); + +export type LayoutUndoResult = + | { + undone: true; + confirmation_required?: false; + screen: Id; + revision: number; + } + | { + undone: false; + confirmation_required: true; + screen: Id; + revision: number; + closes_panes: Id[]; + }; export interface PaneNeighborRequest extends CmuxRequestBase { cmd: "pane-neighbor"; pane: Id; dir: PaneDirection } export interface PaneNeighborResult { pane: Id | null } @@ -712,9 +760,12 @@ export type CmuxRequest = | CreateTerminalRequest | NewScreenRequest | NewPaneRequest + | NewPaneRightRequest | SplitRequest | SetRatioRequest | SetSplitRatioRequest + | SetViewportPaneWidthRequest + | UndoLayoutRequest | PaneNeighborRequest | FocusDirectionRequest | SwapPaneRequest @@ -781,8 +832,11 @@ export interface CmuxResponseDataMap { "create-terminal": TerminalPlacement; "new-screen": SurfaceResult; "new-pane": SurfaceResult; + "new-pane-right": SurfaceResult; split: SurfaceResult; "set-ratio": EmptyResult; + "set-viewport-pane-width": EmptyResult; + "undo-layout": LayoutUndoResult; "set-split-ratio": EmptyResult; "pane-neighbor": PaneNeighborResult; "focus-direction": FocusDirectionResult; diff --git a/cmux-tui/bindings/typescript/src/protocol/common.ts b/cmux-tui/bindings/typescript/src/protocol/common.ts index bb4583bebc89..3a0eeb6606e9 100644 --- a/cmux-tui/bindings/typescript/src/protocol/common.ts +++ b/cmux-tui/bindings/typescript/src/protocol/common.ts @@ -45,6 +45,8 @@ export interface CmuxFailureResponse { id?: Json; ok: false; error: string; + /** Additive machine-readable classification for expected command failures. */ + error_code?: string; error_delivery?: CmuxErrorDelivery; } diff --git a/cmux-tui/bindings/typescript/src/protocol/events.ts b/cmux-tui/bindings/typescript/src/protocol/events.ts index 427f54384e54..26a90b5e8952 100644 --- a/cmux-tui/bindings/typescript/src/protocol/events.ts +++ b/cmux-tui/bindings/typescript/src/protocol/events.ts @@ -246,8 +246,14 @@ export interface ColorsChangedEvent extends TerminalColors { export interface BrowserFrame { seq: number; + /** CSS viewport width used for browser input mapping, or 0 when CDP omits it. */ width: number; + /** CSS viewport height used for browser input mapping, or 0 when CDP omits it. */ height: number; + /** Encoded PNG width. Older servers omit this field. */ + image_width?: number; + /** Encoded PNG height. Older servers omit this field. */ + image_height?: number; data: Base64; } @@ -355,14 +361,31 @@ export interface DecodedResizedEvent extends Omit { + frame?: DecodedBrowserFrame | null; +} + +/** A frame event with normalized image dimensions. */ +export interface DecodedBrowserFrameEvent extends BrowserFrameEvent { + image_width: number; + image_height: number; +} + /** Attach events as yielded by the client after base64 decoding. */ export type DecodedAttachEvent = | DecodedVtStateEvent | DecodedOutputEvent | DecodedResizedEvent | DecodedColorsChangedEvent - | BrowserStateEvent - | BrowserFrameEvent + | DecodedBrowserStateEvent + | DecodedBrowserFrameEvent | ScrollChangedEvent | DetachedEvent | OverflowEvent diff --git a/cmux-tui/bindings/typescript/src/protocol/tree.ts b/cmux-tui/bindings/typescript/src/protocol/tree.ts index 2b29d5fdda2e..0340a554cb98 100644 --- a/cmux-tui/bindings/typescript/src/protocol/tree.ts +++ b/cmux-tui/bindings/typescript/src/protocol/tree.ts @@ -55,6 +55,12 @@ export interface DeadPane { /** A pane in a tree snapshot. */ export type Pane = LivePane | DeadPane; +/** One appended horizontal viewport column and its frontend-relative width. */ +export interface ViewportSplit { + split: Id; + width: number; +} + /** A named split-tree screen. */ export interface Screen { id: Id; @@ -63,6 +69,10 @@ export interface Screen { active_pane: Id; zoomed_pane: Id | null; layout: Layout; + /** First viewport column width; absent on ordinary tiled screens. */ + viewport_base_width?: number; + /** Appended viewport columns; absent on ordinary tiled screens. */ + viewport_splits?: ViewportSplit[]; panes: Pane[]; } diff --git a/cmux-tui/bindings/typescript/src/types.ts b/cmux-tui/bindings/typescript/src/types.ts index 462c067aea21..974df62924c9 100644 --- a/cmux-tui/bindings/typescript/src/types.ts +++ b/cmux-tui/bindings/typescript/src/types.ts @@ -6,7 +6,9 @@ export type { NewBrowserTabOptions, NewScreenOptions, NewTabOptions, + NewPaneRightOptions, NewWorkspaceOptions, + ResizeTransactionOptions, SelectOptions, SelectTabOptions, SendOptions, diff --git a/cmux-tui/bindings/typescript/test/client.test.ts b/cmux-tui/bindings/typescript/test/client.test.ts index d9dbb750d096..35ca9c748a13 100644 --- a/cmux-tui/bindings/typescript/test/client.test.ts +++ b/cmux-tui/bindings/typescript/test/client.test.ts @@ -69,6 +69,222 @@ test("stream fails closed at the default buffered-event cap", async () => { assert.equal(cleanups, 1); }); +test("command errors expose machine-readable codes", async () => { + const transport = new ScriptedTransport((request, connection) => { + connection.emit({ + id: request.id, + ok: false, + error: "layout changed", + error_code: "layout-undo-stale", + }); + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + + await assert.rejects(client.request("ping"), (error: unknown) => { + assert.ok(error instanceof CmuxCommandError); + assert.equal(error.errorCode, "layout-undo-stale"); + return true; + }); + await client.close(); +}); + +test("decoded browser frames preserve encoded dimensions and fill legacy defaults", async () => { + const transport = new ScriptedTransport((request, connection) => { + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 10, session: "main", pid: 1 }, + }); + return; + } + connection.emit({ + event: "frame", + surface: 7, + seq: 1, + width: 80, + height: 24, + data: "cG5n", + }); + connection.emit({ + event: "frame", + surface: 7, + seq: 2, + width: 80, + height: 24, + image_width: 160, + image_height: 48, + data: "cG5n", + }); + connection.emit({ + event: "browser-state", + surface: 7, + cols: 80, + rows: 24, + url: "https://example.com", + title: "Example", + status: "ready", + error: null, + frames_stalled: false, + frame: { + seq: 3, + width: 80, + height: 24, + data: "cG5n", + }, + }); + connection.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport, + timeoutMs: 100, + allowProtocolV6Attach: true, + }); + const stream = await client.attachSurface(7); + + const legacy = await stream.next(); + const scaled = await stream.next(); + const state = await stream.next(); + assert.equal(legacy.event, "frame"); + assert.equal(scaled.event, "frame"); + if (legacy.event === "frame" && scaled.event === "frame") { + assert.equal(legacy.image_width, 80); + assert.equal(legacy.image_height, 24); + assert.equal(scaled.image_width, 160); + assert.equal(scaled.image_height, 48); + } + assert.equal(state.event, "browser-state"); + if ( + state.event === "browser-state" + && "frame" in state + && state.frame + && typeof state.frame === "object" + && "image_width" in state.frame + && "image_height" in state.frame + ) { + assert.equal(state.frame.image_width, 80); + assert.equal(state.frame.image_height, 24); + } + stream.close(); + await client.close(); +}); + +test("decoded browser frames accept missing CSS dimensions from CDP metadata", async () => { + const transport = new ScriptedTransport((request, connection) => { + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 10, session: "main", pid: 1 }, + }); + return; + } + connection.emit({ + event: "frame", + surface: 7, + seq: 1, + width: 0, + height: 0, + image_width: 160, + image_height: 48, + data: "cG5n", + }); + connection.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport, + timeoutMs: 100, + allowProtocolV6Attach: true, + }); + const stream = await client.attachSurface(7); + + const frame = await stream.next(); + assert.equal(frame.event, "frame"); + if (frame.event === "frame") { + assert.equal(frame.width, 0); + assert.equal(frame.height, 0); + assert.equal(frame.image_width, 160); + assert.equal(frame.image_height, 48); + } + stream.close(); + await client.close(); +}); + +test("browser frames reject missing, nonnumeric, and invalid dimensions", async () => { + const malformed = [ + { + event: { + event: "frame", + surface: 7, + seq: 1, + height: 24, + data: "cG5n", + }, + error: /frame width is not a nonnegative integer/, + }, + { + event: { + event: "frame", + surface: 7, + seq: 1, + width: "80", + height: 24, + data: "cG5n", + }, + error: /frame width is not a nonnegative integer/, + }, + { + event: { + event: "frame", + surface: 7, + seq: 1, + width: 80, + height: -1, + data: "cG5n", + }, + error: /frame height is not a nonnegative integer/, + }, + { + event: { + event: "browser-state", + surface: 7, + frame: { + seq: 1, + width: 80, + height: 24, + image_width: 0, + image_height: 48, + data: "cG5n", + }, + }, + error: /browser-state frame image_width is not a positive integer/, + }, + ]; + + for (const sample of malformed) { + const transport = new ScriptedTransport((request, connection) => { + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { app: "cmux-tui", version: "0.1.2", protocol: 10, session: "main", pid: 1 }, + }); + return; + } + connection.emit(sample.event); + connection.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ + transport, + timeoutMs: 100, + allowProtocolV6Attach: true, + }); + + await assert.rejects(() => client.attachSurface(7), sample.error); + await client.close(); + } +}); + test("async iteration reports buffered-event overflow before the first pull", async () => { const stream = new CmuxStream<{ event: string }>(100, () => undefined, 1); stream.push({ event: "first" }); @@ -135,7 +351,7 @@ test("attach buffering enforces aggregate bytes and browser-frame limits", async { event: "output", surface: 7, data: "YWJj" }, { event: "output", surface: 7, data: "ZGVm" }, ], - [{ event: "frame", surface: 7, data: "AAAAA" }], + [{ event: "frame", surface: 7, seq: 1, width: 80, height: 24, data: "AAAAA" }], [{ event: "browser-state", surface: 7, @@ -208,6 +424,67 @@ test("newPane rejects servers older than protocol 9", async () => { await client.close(); }); +test("newPaneRight rejects invalid widths before transport", async () => { + const requests: Record[] = []; + const transport = new ScriptedTransport((request, connection) => { + requests.push(request); + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { + app: "cmux-tui", + version: "0.1.2", + protocol: 10, + capabilities: ["viewport-splits-v1"], + session: "main", + pid: 1, + }, + }); + return; + } + connection.emit({ id: request.id, ok: true, data: { surface: 9 } }); + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + + for (const width of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, 0.09, 1.01]) { + await assert.rejects( + client.newPaneRight(7, { width }), + /viewport pane width must be between 0.1 and 1.0/, + ); + } + assert.deepEqual(requests, []); + await client.close(); +}); + +test("newPaneRight preserves the typed null width default", async () => { + const requests: Record[] = []; + const transport = new ScriptedTransport((request, connection) => { + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { + app: "cmux-tui", + version: "0.1.2", + protocol: 10, + capabilities: ["viewport-splits-v1"], + session: "main", + pid: 1, + }, + }); + return; + } + requests.push(request); + connection.emit({ id: request.id, ok: true, data: { surface: 9 } }); + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + + assert.deepEqual(await client.newPaneRight(7, { width: null }), { surface: 9 }); + assert.deepEqual(requests, [{ id: 2, cmd: "new-pane-right", pane: 7, width: null }]); + await client.close(); +}); + test("clearHistory rejects servers without the advertised capability", async () => { let clearRequests = 0; const transport = new ScriptedTransport((request, connection) => { @@ -416,6 +693,128 @@ test("setSplitRatio accepts newer additive protocols", async () => { await client.close(); }); +test("undoLayout preserves the preview revision for confirmation", async () => { + const requests: Record[] = []; + const transport = new ScriptedTransport((request, connection) => { + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { + app: "cmux-tui", + version: "0.1.2", + protocol: 10, + capabilities: ["layout-undo-v1"], + session: "main", + pid: 1, + }, + }); + return; + } + requests.push(request); + if (request.confirm_close === true) { + connection.emit({ + id: request.id, + ok: true, + data: { undone: true, screen: 3, revision: 9 }, + }); + } else { + connection.emit({ + id: request.id, + ok: true, + data: { + undone: false, + confirmation_required: true, + screen: 3, + revision: 8, + closes_panes: [15], + }, + }); + } + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + + const preview = await client.undoLayout(15); + assert.equal(preview.undone, false); + if (preview.undone) throw new Error("expected confirmation preview"); + const result = await client.undoLayout(15, preview.revision); + assert.equal(result.undone, true); + assert.deepEqual(requests, [ + { id: 2, cmd: "undo-layout", pane: 15 }, + { id: 3, cmd: "undo-layout", pane: 15, revision: 8, confirm_close: true }, + ]); + await client.close(); +}); + +test("undoLayout rejects malformed result variants", async (t) => { + const invalidResults: Record[] = [ + { + undone: false, + confirmation_required: true, + screen: 3, + revision: 8, + }, + { + confirmation_required: true, + screen: 3, + revision: 8, + closes_panes: [15], + }, + { + undone: true, + confirmation_required: true, + screen: 3, + revision: 8, + closes_panes: [15], + }, + { + undone: false, + confirmation_required: true, + screen: 3, + revision: -1, + closes_panes: [15], + }, + { + undone: false, + confirmation_required: true, + screen: 3, + revision: 8, + closes_panes: [1.5], + }, + ]; + + for (const [index, data] of invalidResults.entries()) { + await t.test(`case ${index + 1}`, async () => { + const transport = new ScriptedTransport((request, connection) => { + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { + app: "cmux-tui", + version: "0.1.2", + protocol: 10, + capabilities: ["layout-undo-v1"], + session: "main", + pid: 1, + }, + }); + return; + } + connection.emit({ id: request.id, ok: true, data }); + }); + const client = new CmuxClient({ transport, timeoutMs: 100 }); + + await assert.rejects( + client.undoLayout(15), + (error: unknown) => error instanceof CmuxProtocolError + && error.message.includes("layout undo"), + ); + await client.close(); + }); + } +}); + test("stable terminal resolve and close serialize process identity", async () => { const terminalId = "0123456789abcdef0123456789abcdef"; const incarnation = "fedcba9876543210fedcba9876543210"; @@ -970,6 +1369,39 @@ test("setSplitRatio sends the stable split id", async () => { await client.close(); }); +test("resize methods forward one explicit transaction across drag samples", async () => { + const sent: Record[] = []; + const transport = new ScriptedTransport((request, connection) => { + if (request.cmd === "identify") { + connection.emit({ + id: request.id, + ok: true, + data: { + app: "cmux-tui", + version: "0.1.2", + protocol: 10, + capabilities: ["viewport-column-resize-v1"], + session: "main", + pid: 1, + }, + }); + return; + } + sent.push(request); + connection.emit({ id: request.id, ok: true, data: {} }); + }); + const client = new CmuxClient({ transport }); + + await client.setSplitRatio(42, 0.6, { transaction: 17 }); + await client.setViewportPaneWidth(9, 0.75, { transaction: 17 }); + + assert.deepEqual(sent, [ + { id: 2, cmd: "set-split-ratio", split: 42, ratio: 0.6, transaction: 17 }, + { id: 3, cmd: "set-viewport-pane-width", pane: 9, width: 0.75, transaction: 17 }, + ]); + await client.close(); +}); + test("listClients returns the exact client presence response shape", async () => { const response = [{ client: 7, diff --git a/cmux-tui/bindings/typescript/test/protocol-types.ts b/cmux-tui/bindings/typescript/test/protocol-types.ts index f6f7fae28ee4..7ecd3430db1d 100644 --- a/cmux-tui/bindings/typescript/test/protocol-types.ts +++ b/cmux-tui/bindings/typescript/test/protocol-types.ts @@ -1,20 +1,62 @@ import type { CmuxClient, CmuxEvent, + CmuxFailureResponse, CmuxRequest, CmuxResponseData, CmuxStream, + BrowserFrame, DecodedAttachEvent, + ExportLayoutResult, KnownCmuxEvent, + NewPaneRightOptions, RenderAttachEvent, RenderDeltaEvent, RenderStateEvent, + Screen, Tree, TreeDeltaEvent, } from "../src/browser.js"; +const failureResponse: CmuxFailureResponse = { + ok: false, + error: "layout changed", + error_code: "layout-undo-stale", +}; +void failureResponse; +const scaledBrowserFrame: BrowserFrame = { + seq: 1, + width: 80, + height: 24, + image_width: 160, + image_height: 48, + data: "cG5n", +}; +void scaledBrowserFrame; +const exportedViewportLayout: ExportLayoutResult = { + layout: { type: "leaf", pane: 1 }, + panes: [{ pane: 1, surfaces: [2] }], + viewport_base_width: 1, + viewport_splits: [{ split: 3, width: 2 / 3 }], +}; +void exportedViewportLayout; + const treeWithPaneRevision: Tree = { pane_revision: 7, workspaces: [] }; void treeWithPaneRevision; +const viewportScreen: Screen = { + id: 1, + name: null, + active: true, + active_pane: 2, + zoomed_pane: null, + layout: { type: "leaf", pane: 2 }, + viewport_base_width: 1, + viewport_splits: [{ split: 3, width: 2 / 3 }], + panes: [], +}; +void viewportScreen; +const browserViewportOptions: NewPaneRightOptions = { width: 2 / 3 }; +void browserViewportOptions; const requests = [ { cmd: "identify" }, @@ -43,9 +85,13 @@ const requests = [ { cmd: "new-workspace", name: "sdk" }, { cmd: "new-screen", workspace: 1 }, { cmd: "new-pane", pane: 1 }, + { cmd: "new-pane-right", pane: 1, width: 2 / 3 }, { cmd: "split", pane: 1, dir: "right" }, { cmd: "set-ratio", pane: 1, dir: "down", ratio: 0.5 }, - { cmd: "set-split-ratio", split: 2, ratio: 0.5 }, + { cmd: "set-split-ratio", split: 2, ratio: 0.5, transaction: 9 }, + { cmd: "set-viewport-pane-width", pane: 1, width: 0.75, transaction: 9 }, + { cmd: "undo-layout", pane: 1 }, + { cmd: "undo-layout", pane: 1, revision: 8, confirm_close: true }, { cmd: "pane-neighbor", pane: 1, dir: "left" }, { cmd: "focus-direction", dir: "up" }, { cmd: "swap-pane", pane: 1, target: 2 }, diff --git a/cmux-tui/crates/cmux-tui-cdp/src/client.rs b/cmux-tui/crates/cmux-tui-cdp/src/client.rs index 2ca46e444306..016a43a87b98 100644 --- a/cmux-tui/crates/cmux-tui-cdp/src/client.rs +++ b/cmux-tui/crates/cmux-tui-cdp/src/client.rs @@ -7,6 +7,7 @@ use std::sync::mpsc::{Receiver, Sender, SyncSender, TryRecvError, TrySendError, use std::sync::{Arc, Mutex, Weak}; use std::time::{Duration, Instant}; +use base64::Engine; use serde_json::{Value, json}; use tungstenite::client::IntoClientRequest; use tungstenite::{Error as WsError, Message, WebSocket, client}; @@ -33,6 +34,8 @@ pub struct ScreencastFrame { pub data_b64: String, pub css_width: u32, pub css_height: u32, + pub image_width: u32, + pub image_height: u32, pub ack_id: u64, } @@ -795,21 +798,47 @@ fn screencast_frame(params: &Value, session_id: &str) -> Option .get("deviceWidth") .and_then(|v| v.as_u64()) .or_else(|| metadata.get("width").and_then(|v| v.as_u64())) - .unwrap_or(0) as u32; + .and_then(|width| u32::try_from(width).ok()) + .unwrap_or(0); let css_height = metadata .get("deviceHeight") .and_then(|v| v.as_u64()) .or_else(|| metadata.get("height").and_then(|v| v.as_u64())) - .unwrap_or(0) as u32; + .and_then(|height| u32::try_from(height).ok()) + .unwrap_or(0); + let (image_width, image_height) = png_dimensions(supplied).unwrap_or((css_width, css_height)); Some(ScreencastFrame { session_id: session_id.to_string(), data_b64: supplied.to_string(), css_width, css_height, + image_width, + image_height, ack_id, }) } +fn png_dimensions(data_b64: &str) -> Option<(u32, u32)> { + const PNG_HEADER_BYTES: usize = 24; + const PNG_HEADER_BASE64_BYTES: usize = 32; + const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + + let encoded_header = data_b64.get(..PNG_HEADER_BASE64_BYTES)?; + let mut header = [0_u8; PNG_HEADER_BYTES]; + let decoded = + base64::engine::general_purpose::STANDARD.decode_slice(encoded_header, &mut header).ok()?; + if decoded != PNG_HEADER_BYTES + || &header[..8] != PNG_SIGNATURE + || header[8..12] != [0, 0, 0, 13] + || &header[12..16] != b"IHDR" + { + return None; + } + let width = u32::from_be_bytes(header[16..20].try_into().ok()?); + let height = u32::from_be_bytes(header[20..24].try_into().ok()?); + (width > 0 && height > 0).then_some((width, height)) +} + fn canonical_base64_decoded_len(input: &str) -> Option { let bytes = input.as_bytes(); if !bytes.len().is_multiple_of(4) { @@ -1068,7 +1097,22 @@ mod tests { "metadata": {"deviceWidth": 80, "deviceHeight": 24} }); - assert_eq!(screencast_frame(¶ms, "session-1").unwrap().data_b64, "aGk="); + let frame = screencast_frame(¶ms, "session-1").unwrap(); + assert_eq!(frame.data_b64, "aGk="); + assert_eq!((frame.image_width, frame.image_height), (80, 24)); + } + + #[test] + fn screencast_frame_preserves_encoded_png_dimensions_separately_from_css_dimensions() { + let params = json!({ + "data": "iVBORw0KGgoAAAANSUhEUgAAAAMAAAAC", + "sessionId": 7, + "metadata": {"deviceWidth": 80, "deviceHeight": 24} + }); + + let frame = screencast_frame(¶ms, "session-1").unwrap(); + assert_eq!((frame.css_width, frame.css_height), (80, 24)); + assert_eq!((frame.image_width, frame.image_height), (3, 2)); } #[test] diff --git a/cmux-tui/crates/cmux-tui-cdp/tests/fake_cdp.rs b/cmux-tui/crates/cmux-tui-cdp/tests/fake_cdp.rs index c49cc9aac6c8..76522b697eae 100644 --- a/cmux-tui/crates/cmux-tui-cdp/tests/fake_cdp.rs +++ b/cmux-tui/crates/cmux-tui-cdp/tests/fake_cdp.rs @@ -128,6 +128,8 @@ fn fake_cdp_flat_sessions_correlation_events_and_screencast_ack() { assert_eq!(frame.data_b64, "iVBORw0KGgo="); assert_eq!(frame.css_width, 640); assert_eq!(frame.css_height, 480); + assert_eq!(frame.image_width, 640); + assert_eq!(frame.image_height, 480); assert_eq!(frame.ack_id, 42); } other => panic!("unexpected event: {other:?}"), diff --git a/cmux-tui/crates/cmux-tui-core/src/browser.rs b/cmux-tui/crates/cmux-tui-core/src/browser.rs index 1718b01a51a2..70c7f6d91e99 100644 --- a/cmux-tui/crates/cmux-tui-core/src/browser.rs +++ b/cmux-tui/crates/cmux-tui-core/src/browser.rs @@ -35,6 +35,8 @@ pub struct BrowserFrame { pub data_b64: String, pub css_width: u32, pub css_height: u32, + pub image_width: u32, + pub image_height: u32, pub seq: u64, } @@ -105,7 +107,7 @@ struct BrowserSession { } struct BrowserState { - latest_frame: Option, + latest_frame: Option>, // Latest-wins attach frame taps. Broadcast overwrites each slot and // sends one wakeup; a slow client skips old frames but stays attached. taps: Vec, @@ -843,6 +845,8 @@ fn start_surface_thread( data_b64: frame.data_b64, css_width: frame.css_width, css_height: frame.css_height, + image_width: frame.image_width, + image_height: frame.image_height, seq: 0, }; browser.store_frame(frame); @@ -1143,7 +1147,7 @@ fn emit_browser_failure(mux: &Weak, id: SurfaceId, message: String) { } impl BrowserSurface { - pub fn latest_frame(&self) -> Option { + pub fn latest_frame(&self) -> Option> { let state = self.state.lock().unwrap(); if matches!(state.status, BrowserStatus::Failed(_)) { None @@ -1152,6 +1156,11 @@ impl BrowserSurface { } } + pub fn has_latest_frame(&self) -> bool { + let state = self.state.lock().unwrap(); + !matches!(state.status, BrowserStatus::Failed(_)) && state.latest_frame.is_some() + } + pub fn title(&self) -> String { self.state.lock().unwrap().title.clone() } @@ -1477,9 +1486,10 @@ impl BrowserSurface { state.last_frame_at = Some(Instant::now()); state.stall_nudged = false; state.page_viewport = Some((frame.css_width.max(1), frame.css_height.max(1))); + let frame = Arc::new(frame); state.latest_frame = Some(frame.clone()); state.taps.retain(|tap| { - tap.slot.lock().unwrap().frame = Some(frame.clone()); + tap.slot.lock().unwrap().frame = Some(frame.as_ref().clone()); match tap.notify.try_send(()) { Ok(()) | Err(TrySendError::Full(())) => true, Err(TrySendError::Disconnected(())) => false, @@ -1988,7 +1998,7 @@ fn browser_attach_state_locked( cols: state.size.0, rows: state.size.1, status: state.status.clone(), - frame: include_frame.then(|| state.latest_frame.clone()).flatten(), + frame: include_frame.then(|| state.latest_frame.as_deref().cloned()).flatten(), frames_stalled: frames_stalled_locked(state, now, dead), } } @@ -2163,6 +2173,8 @@ mod tests { data_b64: "AAAA".to_string(), css_width: 80, css_height: 48, + image_width: 80, + image_height: 48, seq, } } @@ -2279,6 +2291,22 @@ mod tests { assert_eq!(browser.latest_frame().map(|frame| frame.seq), Some(2)); } + #[test] + fn repeated_latest_frame_reads_share_the_encoded_payload() { + let surface = test_surface(); + let browser = surface.as_browser().expect("browser surface"); + browser.store_frame(test_frame(1)); + + let first = browser.latest_frame().expect("first frame"); + let second = browser.latest_frame().expect("second frame"); + + assert_eq!( + first.data_b64.as_ptr(), + second.data_b64.as_ptr(), + "reading the current frame must not copy its encoded image payload" + ); + } + #[test] fn capture_scale_respects_budget_and_fixed_override() { let opts = BrowserCaptureOptions { max_capture_megapixels: 2.0, fixed_capture_scale: None }; @@ -2650,6 +2678,8 @@ mod tests { data_b64: format!("frame-{index}"), css_width: 80, css_height: 24, + image_width: 80, + image_height: 24, ack_id: index, }) }; @@ -2673,6 +2703,8 @@ mod tests { data_b64: "frame-latest".to_string(), css_width: 80, css_height: 24, + image_width: 80, + image_height: 24, ack_id: 1, }); assert!(!route.deliver(frame)); @@ -2709,6 +2741,8 @@ mod tests { data_b64: "frame-final".to_string(), css_width: 80, css_height: 24, + image_width: 80, + image_height: 24, ack_id: 1, })); diff --git a/cmux-tui/crates/cmux-tui-core/src/layout.rs b/cmux-tui/crates/cmux-tui-core/src/layout.rs index f6abebe20ac7..6be88db32c2a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/layout.rs +++ b/cmux-tui/crates/cmux-tui-core/src/layout.rs @@ -1,9 +1,13 @@ //! Pure layout math shared by frontends: a screen's split tree plus a //! rectangle produce pane rects that tile the area exactly. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; -use crate::{Node, PaneId, SplitDir, SplitId}; +use crate::{Node, PaneId, SplitDir, SplitId, ViewportColumn}; + +pub const DEFAULT_VIEWPORT_PANE_WIDTH: f32 = 2.0 / 3.0; +pub const MIN_VIEWPORT_PANE_WIDTH: f32 = 0.1; +pub const MAX_VIEWPORT_PANE_WIDTH: f32 = 1.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Rect { @@ -19,12 +23,51 @@ impl Rect { } } +/// A pane rectangle in a horizontally extended viewport. +/// +/// Terminal-local dimensions remain `u16`, but horizontal position and +/// extent use `u64` so an arbitrary number of columns never collapse onto +/// `u16::MAX`. Frontends narrow these coordinates only after viewport clipping. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct VirtualRect { + pub x: u64, + pub y: u16, + pub width: u64, + pub height: u16, +} + +impl VirtualRect { + pub fn contains(&self, x: u64, y: u16) -> bool { + x >= self.x + && x < self.x.saturating_add(self.width) + && y >= self.y + && y < self.y.saturating_add(self.height) + } +} + +impl From for VirtualRect { + fn from(rect: Rect) -> Self { + Self { x: u64::from(rect.x), y: rect.y, width: u64::from(rect.width), height: rect.height } + } +} + +/// Bounding geometry for one horizontal viewport column. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ViewportColumnRect { + pub owner: ViewportColumn, + /// Any visible pane in the column, suitable for pane-addressed commands. + pub representative: PaneId, + pub rect: VirtualRect, +} + #[derive(Debug, Default)] pub struct LayoutResult { pub panes: Vec<(PaneId, Rect)>, /// Pane rows that represent collapsed Zellij stack headers rather than /// terminal content. pub stacked_headers: HashSet, + /// Horizontal extent occupied by the layout, including viewport columns. + pub virtual_width: u16, } impl LayoutResult { @@ -56,6 +99,56 @@ impl LayoutResult { } } +#[derive(Debug, Default)] +pub struct ViewportLayoutResult { + pub panes: Vec<(PaneId, VirtualRect)>, + /// Column bounds emitted during the same traversal as `panes`. + pub columns: Vec, + /// Pane rows that represent collapsed Zellij stack headers rather than + /// terminal content. + pub stacked_headers: HashSet, + /// Horizontal extent occupied by all viewport columns. + pub virtual_width: u64, +} + +impl ViewportLayoutResult { + pub fn rect_of(&self, pane: PaneId) -> Option { + self.panes.iter().find(|(id, _)| *id == pane).map(|(_, rect)| *rect) + } + + pub fn pane_at(&self, x: u64, y: u16) -> Option { + self.panes.iter().find(|(_, rect)| rect.contains(x, y)).map(|(id, _)| *id) + } + + pub fn neighbor(&self, from: PaneId, dx: i32, dy: i32) -> Option { + virtual_directional_neighbor(&self.panes, from, dx, dy) + } + + pub fn neighbor_by_recency( + &self, + from: PaneId, + dx: i32, + dy: i32, + recency: impl Fn(PaneId) -> R, + ) -> Option { + virtual_directional_neighbor_by_recency(&self.panes, from, dx, dy, recency) + } +} + +impl From for ViewportLayoutResult { + fn from(layout: LayoutResult) -> Self { + let mut result = Self { + stacked_headers: layout.stacked_headers, + virtual_width: u64::from(layout.virtual_width), + ..Default::default() + }; + for (pane, rect) in layout.panes { + record_viewport_pane(&mut result, ViewportColumn::Base, pane, rect.into()); + } + result + } +} + pub fn directional_neighbor( panes: &[(PaneId, Rect)], from: PaneId, @@ -112,6 +205,52 @@ pub fn directional_neighbor_by_recency( .map(|(_, _, id)| id) } +fn virtual_directional_neighbor( + panes: &[(PaneId, VirtualRect)], + from: PaneId, + dx: i32, + dy: i32, +) -> Option { + let current = panes.iter().find(|(id, _)| *id == from).map(|(_, rect)| *rect)?; + let direction = Direction::from_delta(dx, dy)?; + panes + .iter() + .copied() + .enumerate() + .filter(|(_, (id, rect))| *id != from && rect.width > 0 && rect.height > 0) + .filter_map(|(order, (id, rect))| { + direction.score_virtual(current, rect).map(|score| (order, id, score)) + }) + .min_by_key(|(order, _, score)| (std::cmp::Reverse(score.overlap), score.distance, *order)) + .map(|(_, id, _)| id) +} + +fn virtual_directional_neighbor_by_recency( + panes: &[(PaneId, VirtualRect)], + from: PaneId, + dx: i32, + dy: i32, + recency: impl Fn(PaneId) -> R, +) -> Option { + let current = panes.iter().find(|(id, _)| *id == from).map(|(_, rect)| *rect)?; + let direction = Direction::from_delta(dx, dy)?; + panes + .iter() + .copied() + .enumerate() + .filter(|(_, (id, rect))| *id != from && rect.width > 0 && rect.height > 0) + .filter_map(|(order, (id, rect))| { + direction + .score_virtual(current, rect) + .filter(|score| score.distance == 0 && score.overlap > 0) + .map(|_| (recency(id), order, id)) + }) + .max_by(|(a_recency, a_order, _), (b_recency, b_order, _)| { + a_recency.cmp(b_recency).then_with(|| b_order.cmp(a_order)) + }) + .map(|(_, _, id)| id) +} + #[derive(Clone, Copy)] enum Direction { Left, @@ -126,6 +265,12 @@ struct NeighborScore { distance: u16, } +#[derive(Clone, Copy)] +struct VirtualNeighborScore { + overlap: u64, + distance: u64, +} + impl Direction { fn from_delta(dx: i32, dy: i32) -> Option { if dx < 0 { @@ -176,6 +321,69 @@ impl Direction { }; (overlap > 0).then_some(NeighborScore { overlap, distance }) } + + fn score_virtual( + self, + current: VirtualRect, + candidate: VirtualRect, + ) -> Option { + let (overlap, distance) = match self { + Direction::Left => { + let candidate_max = candidate.x.saturating_add(candidate.width); + if candidate_max > current.x { + return None; + } + ( + overlap_len_u64( + u64::from(current.y), + u64::from(current.height), + u64::from(candidate.y), + u64::from(candidate.height), + ), + current.x - candidate_max, + ) + } + Direction::Right => { + let current_max = current.x.saturating_add(current.width); + if candidate.x < current_max { + return None; + } + ( + overlap_len_u64( + u64::from(current.y), + u64::from(current.height), + u64::from(candidate.y), + u64::from(candidate.height), + ), + candidate.x - current_max, + ) + } + Direction::Up => { + let current_min = u64::from(current.y); + let candidate_max = + u64::from(candidate.y).saturating_add(u64::from(candidate.height)); + if candidate_max > current_min { + return None; + } + ( + overlap_len_u64(current.x, current.width, candidate.x, candidate.width), + current_min - candidate_max, + ) + } + Direction::Down => { + let current_max = u64::from(current.y).saturating_add(u64::from(current.height)); + let candidate_min = u64::from(candidate.y); + if candidate_min < current_max { + return None; + } + ( + overlap_len_u64(current.x, current.width, candidate.x, candidate.width), + candidate_min - current_max, + ) + } + }; + (overlap > 0).then_some(VirtualNeighborScore { overlap, distance }) + } } fn overlap_len(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> u16 { @@ -184,6 +392,12 @@ fn overlap_len(a_start: u16, a_len: u16, b_start: u16, b_len: u16) -> u16 { end.saturating_sub(start) } +fn overlap_len_u64(a_start: u64, a_len: u64, b_start: u64, b_len: u64) -> u64 { + let start = a_start.max(b_start); + let end = a_start.saturating_add(a_len).min(b_start.saturating_add(b_len)); + end.saturating_sub(start) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SplitEdge { Left, @@ -218,15 +432,64 @@ pub struct ExactSplitResize { pub split: SplitId, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExactViewportSplitResize { + pub area: VirtualRect, + pub split: SplitId, +} + +impl From for ExactViewportSplitResize { + fn from(resize: ExactSplitResize) -> Self { + Self { area: resize.area.into(), split: resize.split } + } +} + /// Compute pane rects for a screen. Panes tile the area exactly; each /// pane draws its own border box inside its rect, so no divider cells /// are reserved between siblings. pub fn layout_screen(root: &Node, area: Rect, active_pane: Option) -> LayoutResult { - let mut result = LayoutResult::default(); + let mut result = LayoutResult { virtual_width: area.width, ..Default::default() }; walk(root, area, active_pane, &mut result); result } +/// Compute a screen layout with selected right splits extending the horizontal +/// viewport instead of subdividing it. +/// +/// Each marked split maps to the width of its right side as a fraction of the +/// frontend viewport. Unmarked splits keep their ordinary tiled behavior. +pub fn layout_screen_with_viewport( + root: &Node, + area: Rect, + active_pane: Option, + base_width: f32, + viewport_splits: &BTreeMap, +) -> ViewportLayoutResult { + let mut result = ViewportLayoutResult::default(); + let base_area = VirtualRect { + x: u64::from(area.x), + y: area.y, + width: u64::from(viewport_column_cells(area.width, base_width)), + height: area.height, + }; + let end = walk_viewport( + root, + base_area, + area.width, + active_pane, + viewport_splits, + ViewportColumn::Base, + &mut result, + ); + result.virtual_width = end.saturating_sub(u64::from(area.x)).max(u64::from(area.width)); + result +} + +fn viewport_column_cells(viewport_width: u16, width: f32) -> u16 { + let width = width.clamp(MIN_VIEWPORT_PANE_WIDTH, MAX_VIEWPORT_PANE_WIDTH); + ((f32::from(viewport_width) * width).round() as u16).clamp(1, viewport_width.max(1)) +} + /// Reproduce Zellij's default auto-layout sequence for panes in creation /// order. Through twelve panes, the `vertical` family fills columns of four. /// Above twelve panes, Zellij advances to `stacked`: the first pane stays @@ -334,6 +597,151 @@ fn walk(node: &Node, area: Rect, active_pane: Option, out: &mut LayoutRe } } +/// Lay out `node` and return the first unused absolute x coordinate. +fn walk_viewport( + node: &Node, + area: VirtualRect, + viewport_width: u16, + active_pane: Option, + viewport_splits: &BTreeMap, + owner: ViewportColumn, + out: &mut ViewportLayoutResult, +) -> u64 { + match node { + Node::Leaf(id) => { + record_viewport_pane(out, owner, *id, area); + area.x.saturating_add(area.width) + } + Node::Split { id, dir: SplitDir::Right, a, b, .. } if viewport_splits.contains_key(id) => { + let a_end = + walk_viewport(a, area, viewport_width, active_pane, viewport_splits, owner, out); + let width = viewport_column_cells(viewport_width, viewport_splits[id]); + walk_viewport( + b, + VirtualRect { x: a_end, width: u64::from(width), ..area }, + viewport_width, + active_pane, + viewport_splits, + ViewportColumn::Split(*id), + out, + ) + } + Node::Split { dir, ratio, a, b, .. } => { + let too_small = match dir { + SplitDir::Right => area.width < 2, + SplitDir::Down => area.height < 2, + }; + if too_small { + let a_end = walk_viewport( + a, + area, + viewport_width, + active_pane, + viewport_splits, + owner, + out, + ); + let b_end = walk_viewport( + b, + VirtualRect { width: 0, height: 0, ..area }, + viewport_width, + active_pane, + viewport_splits, + owner, + out, + ); + return a_end.max(b_end); + } + let (a_rect, b_rect) = split_virtual_sides(area, *dir, *ratio); + let a_end = + walk_viewport(a, a_rect, viewport_width, active_pane, viewport_splits, owner, out); + let b_end = + walk_viewport(b, b_rect, viewport_width, active_pane, viewport_splits, owner, out); + a_end.max(b_end) + } + Node::Stack { panes, expanded } => { + let panes = panes.as_slice(); + let expanded = active_pane.filter(|pane| panes.contains(pane)).unwrap_or(*expanded); + walk_viewport_stack(panes, expanded, area, owner, out); + area.x.saturating_add(area.width) + } + } +} + +fn record_viewport_pane( + out: &mut ViewportLayoutResult, + owner: ViewportColumn, + pane: PaneId, + rect: VirtualRect, +) { + out.panes.push((pane, rect)); + if rect.width == 0 || rect.height == 0 { + return; + } + if let Some(column) = out.columns.last_mut() + && column.owner == owner + { + let right = + column.rect.x.saturating_add(column.rect.width).max(rect.x.saturating_add(rect.width)); + let bottom = column + .rect + .y + .saturating_add(column.rect.height) + .max(rect.y.saturating_add(rect.height)); + column.rect.x = column.rect.x.min(rect.x); + column.rect.y = column.rect.y.min(rect.y); + column.rect.width = right.saturating_sub(column.rect.x); + column.rect.height = bottom.saturating_sub(column.rect.y); + return; + } + out.columns.push(ViewportColumnRect { owner, representative: pane, rect }); +} + +fn walk_viewport_stack( + panes: &[PaneId], + expanded: PaneId, + area: VirtualRect, + owner: ViewportColumn, + out: &mut ViewportLayoutResult, +) { + let expanded_index = panes.iter().position(|pane| *pane == expanded).unwrap_or(panes.len() - 1); + let visible_headers = usize::from(area.height.saturating_sub(1)).min(panes.len() - 1); + let available_before = expanded_index; + let available_after = panes.len() - expanded_index - 1; + let mut headers_before = 0; + let mut headers_after = 0; + while headers_before + headers_after < visible_headers { + let can_take_before = headers_before < available_before; + let can_take_after = headers_after < available_after; + if can_take_before && (!can_take_after || headers_before <= headers_after) { + headers_before += 1; + } else if can_take_after { + headers_after += 1; + } else { + break; + } + } + let expanded_height = area.height.saturating_sub((headers_before + headers_after) as u16); + + let mut y = area.y; + for (index, pane) in panes.iter().copied().enumerate() { + let height = if index == expanded_index { + expanded_height + } else if index >= expanded_index - headers_before && index < expanded_index + || index > expanded_index && index <= expanded_index + headers_after + { + 1 + } else { + 0 + }; + record_viewport_pane(out, owner, pane, VirtualRect { y, height, ..area }); + if height == 1 && index != expanded_index { + out.stacked_headers.insert(pane); + } + y = y.saturating_add(height); + } +} + fn walk_stack(panes: &[PaneId], expanded: PaneId, area: Rect, out: &mut LayoutResult) { let expanded_index = panes.iter().position(|pane| *pane == expanded).unwrap_or(panes.len() - 1); let visible_headers = usize::from(area.height.saturating_sub(1)).min(panes.len() - 1); @@ -405,6 +813,137 @@ pub fn exact_split_for_pane_edge( best } +/// Find the exact split behind a pane edge in a horizontally extended +/// viewport layout. +pub fn exact_split_for_pane_edge_with_viewport( + root: &Node, + area: Rect, + active_pane: Option, + pane: PaneId, + edge: SplitEdge, + base_width: f32, + viewport_splits: &BTreeMap, +) -> Option { + let pane_rect = + layout_screen_with_viewport(root, area, active_pane, base_width, viewport_splits) + .rect_of(pane)?; + let mut best = None; + let base_area = VirtualRect { + x: u64::from(area.x), + y: area.y, + width: u64::from(viewport_column_cells(area.width, base_width)), + height: area.height, + }; + exact_split_for_pane_edge_viewport_walk( + root, + base_area, + area.width, + active_pane, + viewport_splits, + pane, + pane_rect, + edge, + &mut best, + ); + best +} + +#[allow(clippy::too_many_arguments)] +fn exact_split_for_pane_edge_viewport_walk( + node: &Node, + area: VirtualRect, + viewport_width: u16, + active_pane: Option, + viewport_splits: &BTreeMap, + pane: PaneId, + pane_rect: VirtualRect, + edge: SplitEdge, + best: &mut Option, +) { + let Node::Split { id, dir, ratio, a, b } = node else { + return; + }; + let (a_rect, b_rect, split_area) = + if *dir == SplitDir::Right && viewport_splits.contains_key(id) { + let mut ignored = ViewportLayoutResult::default(); + let a_end = walk_viewport( + a, + area, + viewport_width, + active_pane, + viewport_splits, + ViewportColumn::Base, + &mut ignored, + ); + let width = viewport_column_cells(viewport_width, viewport_splits[id]); + let b_rect = VirtualRect { x: a_end, width: u64::from(width), ..area }; + ( + area, + b_rect, + VirtualRect { + width: b_rect.x.saturating_add(b_rect.width).saturating_sub(area.x), + ..area + }, + ) + } else { + let too_small = match dir { + SplitDir::Right => area.width < 2, + SplitDir::Down => area.height < 2, + }; + if too_small { + return; + } + let (a_rect, b_rect) = split_virtual_sides(area, *dir, *ratio); + (a_rect, b_rect, area) + }; + let pane_in_a = a.contains(pane); + let pane_in_b = b.contains(pane); + if *dir == edge.dir() { + let boundary = match dir { + SplitDir::Right => b_rect.x, + SplitDir::Down => u64::from(b_rect.y), + }; + let matches_boundary = match edge { + SplitEdge::Right => { + pane_in_a && pane_rect.x.saturating_add(pane_rect.width) == boundary + } + SplitEdge::Left => pane_in_b && pane_rect.x == boundary, + SplitEdge::Bottom => { + pane_in_a && u64::from(pane_rect.y.saturating_add(pane_rect.height)) == boundary + } + SplitEdge::Top => pane_in_b && u64::from(pane_rect.y) == boundary, + }; + if matches_boundary { + *best = Some(ExactViewportSplitResize { area: split_area, split: *id }); + } + } + if pane_in_a { + exact_split_for_pane_edge_viewport_walk( + a, + a_rect, + viewport_width, + active_pane, + viewport_splits, + pane, + pane_rect, + edge, + best, + ); + } else if pane_in_b { + exact_split_for_pane_edge_viewport_walk( + b, + b_rect, + viewport_width, + active_pane, + viewport_splits, + pane, + pane_rect, + edge, + best, + ); + } +} + fn exact_split_for_pane_edge_walk( node: &Node, area: Rect, @@ -534,6 +1073,35 @@ pub fn split_sides(area: Rect, dir: SplitDir, ratio: f32) -> (Rect, Rect) { } } +fn split_virtual_sides(area: VirtualRect, dir: SplitDir, ratio: f32) -> (VirtualRect, VirtualRect) { + match dir { + SplitDir::Right => { + let first_width = ((area.width as f64) * f64::from(ratio)).round() as u64; + let first_width = first_width.clamp(1, area.width.saturating_sub(1).max(1)); + ( + VirtualRect { width: first_width, ..area }, + VirtualRect { + x: area.x.saturating_add(first_width), + width: area.width - first_width, + ..area + }, + ) + } + SplitDir::Down => { + let first_height = ((f32::from(area.height)) * ratio).round() as u16; + let first_height = first_height.clamp(1, area.height.saturating_sub(1).max(1)); + ( + VirtualRect { height: first_height, ..area }, + VirtualRect { + y: area.y.saturating_add(first_height), + height: area.height - first_height, + ..area + }, + ) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -553,11 +1121,157 @@ mod tests { assert_eq!(r1.width, 40); assert_eq!(r2.width, 40); assert_eq!(r2.x, 40); + assert_eq!(layout.virtual_width, 80); // Panes tile without gaps: every cell belongs to exactly one pane. assert_eq!(layout.pane_at(39, 0), Some(1)); assert_eq!(layout.pane_at(40, 0), Some(2)); } + #[test] + fn viewport_split_appends_a_two_thirds_width_column() { + let root = Node::Split { + id: 10, + dir: SplitDir::Right, + ratio: 0.9, + a: Box::new(Node::Leaf(1)), + b: Box::new(Node::Leaf(2)), + }; + + let viewport_splits = BTreeMap::from([(10, 2.0 / 3.0)]); + let layout = layout_screen_with_viewport( + &root, + Rect { x: 7, y: 3, width: 80, height: 24 }, + Some(1), + 1.0, + &viewport_splits, + ); + + assert_eq!(layout.rect_of(1), Some(VirtualRect { x: 7, y: 3, width: 80, height: 24 })); + assert_eq!(layout.rect_of(2), Some(VirtualRect { x: 87, y: 3, width: 53, height: 24 })); + assert_eq!( + layout.columns, + vec![ + ViewportColumnRect { + owner: ViewportColumn::Base, + representative: 1, + rect: VirtualRect { x: 7, y: 3, width: 80, height: 24 }, + }, + ViewportColumnRect { + owner: ViewportColumn::Split(10), + representative: 2, + rect: VirtualRect { x: 87, y: 3, width: 53, height: 24 }, + }, + ] + ); + assert_eq!(layout.virtual_width, 133); + } + + #[test] + fn viewport_layout_preserves_columns_past_u16_extent() { + let root = Node::Split { + id: 11, + dir: SplitDir::Right, + ratio: 0.5, + a: Box::new(Node::Split { + id: 10, + dir: SplitDir::Right, + ratio: 0.5, + a: Box::new(Node::Leaf(1)), + b: Box::new(Node::Leaf(2)), + }), + b: Box::new(Node::Leaf(3)), + }; + let viewport_splits = BTreeMap::from([(10, 1.0), (11, 1.0)]); + let layout = layout_screen_with_viewport( + &root, + Rect { x: 0, y: 0, width: u16::MAX, height: 24 }, + Some(3), + 1.0, + &viewport_splits, + ); + + assert_eq!(layout.virtual_width, u64::from(u16::MAX) * 3); + assert_eq!( + layout.rect_of(3), + Some(VirtualRect { + x: u64::from(u16::MAX) * 2, + y: 0, + width: u64::from(u16::MAX), + height: 24, + }) + ); + assert_eq!(layout.pane_at(u64::from(u16::MAX) * 2, 0), Some(3)); + assert_eq!(layout.neighbor(2, 1, 0), Some(3)); + assert_eq!(layout.neighbor(3, -1, 0), Some(2)); + } + + #[test] + fn viewport_split_resize_finds_a_nested_appended_divider() { + let root = Node::Split { + id: 10, + dir: SplitDir::Right, + ratio: 0.5, + a: Box::new(Node::Leaf(1)), + b: Box::new(Node::Split { + id: 20, + dir: SplitDir::Right, + ratio: 0.5, + a: Box::new(Node::Leaf(2)), + b: Box::new(Node::Leaf(3)), + }), + }; + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let viewport_splits = BTreeMap::from([(10, DEFAULT_VIEWPORT_PANE_WIDTH)]); + + assert_eq!( + exact_split_for_pane_edge_with_viewport( + &root, + area, + Some(2), + 2, + SplitEdge::Right, + 1.0, + &viewport_splits, + ), + Some(ExactViewportSplitResize { + area: VirtualRect { x: 80, y: 0, width: 53, height: 24 }, + split: 20, + }) + ); + } + + #[test] + fn viewport_split_preserves_the_existing_tiled_column() { + let root = Node::Split { + id: 10, + dir: SplitDir::Right, + ratio: 0.5, + a: Box::new(Node::Split { + id: 11, + dir: SplitDir::Down, + ratio: 0.25, + a: Box::new(Node::Leaf(1)), + b: Box::new(Node::Leaf(2)), + }), + b: Box::new(Node::Leaf(3)), + }; + + let viewport_splits = BTreeMap::from([(10, 2.0 / 3.0)]); + let layout = layout_screen_with_viewport( + &root, + Rect { x: 0, y: 0, width: 100, height: 40 }, + Some(2), + 1.0, + &viewport_splits, + ); + + assert_eq!(layout.rect_of(1), Some(VirtualRect { x: 0, y: 0, width: 100, height: 10 })); + assert_eq!(layout.rect_of(2), Some(VirtualRect { x: 0, y: 10, width: 100, height: 30 })); + assert_eq!(layout.rect_of(3), Some(VirtualRect { x: 100, y: 0, width: 67, height: 40 })); + assert_eq!(layout.virtual_width, 167); + assert_eq!(layout.neighbor(2, 1, 0), Some(3)); + } + #[test] fn zellij_default_layout_fills_right_column_before_adding_another() { let root = zellij_default_pane_layout(&[1, 2, 3, 4, 5]).unwrap(); diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index dcaaee8f61d9..fae2306f2a63 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -28,18 +28,21 @@ pub mod terminal_host_runtime; pub use browser::{TRANSPORT_SAFE_CAPTURE_MEGAPIXELS, normalize_url}; pub use event_bus::{MuxEventBroadcaster, MuxEventReceiver}; pub use layout::{ - ExactSplitResize, LayoutResult, Rect, SplitEdge, SplitResize, directional_neighbor, - exact_split_for_pane_edge, layout_screen, split_for_pane_edge, split_sides, - zellij_default_pane_layout, + DEFAULT_VIEWPORT_PANE_WIDTH, ExactSplitResize, ExactViewportSplitResize, LayoutResult, + MAX_VIEWPORT_PANE_WIDTH, MIN_VIEWPORT_PANE_WIDTH, Rect, SplitEdge, SplitResize, + ViewportColumnRect, ViewportLayoutResult, VirtualRect, directional_neighbor, + exact_split_for_pane_edge, exact_split_for_pane_edge_with_viewport, layout_screen, + layout_screen_with_viewport, split_for_pane_edge, split_sides, zellij_default_pane_layout, }; -pub use model::{Node, Pane, Screen, State, Workspace}; +pub use model::{Node, Pane, Screen, State, ViewportColumn, Workspace}; pub use mux::{ AgentRecord, AgentSource, AgentState, AppliedLayout, AppliedPane, CellPixelUpdate, - CellPixelUpdateFailure, Direction, LayoutLeafSpec, LayoutSpec, Mux, MuxEvent, - NotificationEvent, NotificationLevel, ProviderWorkspaceAuthority, - ProviderWorkspaceAuthorityStatus, ProviderWorkspaceAuthorityUpdateError, RunPlacement, - SidebarPluginOptions, SidebarPluginStatus, SurfaceNotification, SurfaceResizeReporter, - TreeDelta, TreeDeltaKind, WorkspaceMutationResult, WorkspacePlacement, ZoomMode, ZoomState, + CellPixelUpdateFailure, Direction, LayoutLeafSpec, LayoutRatioError, LayoutSpec, + LayoutUndoError, LayoutUndoResult, Mux, MuxEvent, NotificationEvent, NotificationLevel, + ProviderWorkspaceAuthority, ProviderWorkspaceAuthorityStatus, + ProviderWorkspaceAuthorityUpdateError, RunPlacement, SidebarPluginOptions, SidebarPluginStatus, + SurfaceNotification, SurfaceResizeReporter, TreeDelta, TreeDeltaKind, ViewportWidthError, + WorkspaceMutationResult, WorkspacePlacement, ZoomMode, ZoomState, }; pub use pairing::{PairingChallenge, PairingDecision, PairingError}; pub use short_id::assign_short_ids; diff --git a/cmux-tui/crates/cmux-tui-core/src/model.rs b/cmux-tui/crates/cmux-tui-core/src/model.rs index 3a42856b03e0..ba618832bf14 100644 --- a/cmux-tui/crates/cmux-tui-core/src/model.rs +++ b/cmux-tui/crates/cmux-tui-core/src/model.rs @@ -2,11 +2,77 @@ //! split tree of panes; each pane holds an ordered list of tabs //! (surfaces). -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::sync::Arc; use crate::{PaneId, ScreenId, SplitDir, SplitId, Surface, SurfaceId, WorkspaceId}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewportColumn { + Base, + Split(SplitId), +} + +/// One stable horizontal column in a scrollable screen. +/// +/// `Screen::root` remains the compatibility projection consumed by existing +/// split-tree clients. While columns are active, these records own the real +/// per-column trees and Zellij auto-layout order. +#[derive(Debug, Clone)] +pub(crate) struct LayoutColumn { + pub(crate) id: SplitId, + pub(crate) width: f32, + pub(crate) root: Node, + pub(crate) zellij_auto_layout: Option>, +} + +#[derive(Debug, Clone)] +pub(crate) struct ScreenLayoutSnapshot { + pub root: Node, + pub active_pane: PaneId, + pub zoomed_pane: Option, + pub zellij_auto_layout: Option>, + pub viewport_splits: BTreeMap, + pub viewport_base_width: Option, + pub layout_columns: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LayoutResizeOwner { + InProcess(u64), + ControlClient(u64), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LayoutMutationKey { + Resize { owner: LayoutResizeOwner, transaction: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum ProjectedSplitRatioUpdate { + NotProjected, + Unchanged, + Applied, + Unrepresentable { width: f32 }, +} + +#[derive(Debug, Clone)] +pub(crate) struct LayoutUndoEntry { + pub before: ScreenLayoutSnapshot, + pub after_revision: u64, + pub created_panes: Vec, + pub coalesce: Option, + pub confirmation: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct LayoutUndoConfirmation { + pub revision: u64, + pub pane_tabs: Vec<(PaneId, Vec)>, +} + +const LAYOUT_UNDO_LIMIT: usize = 32; + /// Pane membership for a stack. Construction rejects empty stacks so layout /// and protocol consumers never need to assume a member exists. #[derive(Debug, Clone)] @@ -79,6 +145,12 @@ impl Node { } } + pub fn pane_ids_vec(&self) -> Vec { + let mut panes = Vec::new(); + self.pane_ids(&mut panes); + panes + } + pub fn contains(&self, target: PaneId) -> bool { match self { Node::Leaf(id) => *id == target, @@ -101,7 +173,7 @@ impl Node { match self { Node::Leaf(_) => false, Node::Split { a, b, .. } => a.expand_stack_pane(target) || b.expand_stack_pane(target), - Node::Stack { panes, expanded } if panes.contains(&target) => { + Node::Stack { panes, expanded } if panes.contains(&target) && *expanded != target => { *expanded = target; true } @@ -117,6 +189,40 @@ impl Node { } } + fn collect_stack_expansions(&self, expansions: &mut BTreeMap, PaneId>) { + match self { + Node::Leaf(_) => {} + Node::Split { a, b, .. } => { + a.collect_stack_expansions(expansions); + b.collect_stack_expansions(expansions); + } + Node::Stack { panes, expanded } => { + let mut membership = panes.as_slice().to_vec(); + membership.sort_unstable(); + expansions.insert(membership, *expanded); + } + } + } + + fn restore_stack_expansions(&mut self, expansions: &BTreeMap, PaneId>) { + match self { + Node::Leaf(_) => {} + Node::Split { a, b, .. } => { + a.restore_stack_expansions(expansions); + b.restore_stack_expansions(expansions); + } + Node::Stack { panes, expanded } => { + let mut membership = panes.as_slice().to_vec(); + membership.sort_unstable(); + if let Some(current) = + expansions.get(&membership).copied().filter(|pane| panes.contains(pane)) + { + *expanded = current; + } + } + } + } + pub(crate) fn first_visible_pane(&self) -> PaneId { match self { Node::Leaf(pane) => *pane, @@ -143,7 +249,7 @@ impl Node { true } - fn swap_leaf_ids(&mut self, first: PaneId, second: PaneId) { + pub(crate) fn swap_leaf_ids(&mut self, first: PaneId, second: PaneId) { match self { Node::Leaf(id) if *id == first => *id = second, Node::Leaf(id) if *id == second => *id = first, @@ -213,6 +319,23 @@ impl Node { } } + pub fn viewport_column_owner( + &self, + target: PaneId, + viewport_splits: &BTreeMap, + ) -> Option { + match self { + Node::Split { id, a, b, .. } if viewport_splits.contains_key(id) => { + if b.contains(target) { + Some(ViewportColumn::Split(*id)) + } else { + a.viewport_column_owner(target, viewport_splits) + } + } + node => node.contains(target).then_some(ViewportColumn::Base), + } + } + /// Remove a leaf, collapsing its parent split. Returns None when the /// whole node was the removed leaf. pub(crate) fn remove_leaf(self, target: PaneId) -> Option { @@ -256,31 +379,33 @@ impl Node { dir: SplitDir, new_ratio: f32, ) -> bool { - fn walk(node: &mut Node, target: PaneId, dir: SplitDir, new_ratio: f32) -> (bool, bool) { + let Some(split) = self.deepest_split_for_pane(target, dir) else { + return false; + }; + self.set_split_ratio(split, new_ratio) + } + + pub(crate) fn deepest_split_for_pane(&self, target: PaneId, dir: SplitDir) -> Option { + fn walk(node: &Node, target: PaneId, dir: SplitDir) -> (bool, Option) { match node { - Node::Leaf(id) => (*id == target, false), - Node::Split { dir: split_dir, ratio, a, b, .. } => { - let (a_contains, a_updated) = walk(a, target, dir, new_ratio); - if a_updated { - return (true, true); + Node::Leaf(id) => (*id == target, None), + Node::Split { id, dir: split_dir, a, b, .. } => { + let (a_contains, a_split) = walk(a, target, dir); + if a_split.is_some() { + return (true, a_split); } - let (b_contains, b_updated) = walk(b, target, dir, new_ratio); - if b_updated { - return (true, true); + let (b_contains, b_split) = walk(b, target, dir); + if b_split.is_some() { + return (true, b_split); } let contains = a_contains || b_contains; - if contains && *split_dir == dir { - *ratio = new_ratio; - (true, true) - } else { - (contains, false) - } + if contains && *split_dir == dir { (true, Some(*id)) } else { (contains, None) } } - Node::Stack { panes, .. } => (panes.contains(&target), false), + Node::Stack { panes, .. } => (panes.contains(&target), None), } } - walk(self, target, dir, new_ratio).1 + walk(self, target, dir).1 } pub(crate) fn set_split_ratio(&mut self, target: SplitId, new_ratio: f32) -> bool { @@ -297,6 +422,32 @@ impl Node { Node::Stack { .. } => false, } } + + pub(crate) fn split_ratio(&self, target: SplitId) -> Option { + match self { + Node::Leaf(_) | Node::Stack { .. } => None, + Node::Split { id, ratio, a, b, .. } => { + if *id == target { + Some(*ratio) + } else { + a.split_ratio(target).or_else(|| b.split_ratio(target)) + } + } + } + } + + fn set_split_ratios(&mut self, ratios: &BTreeMap) { + match self { + Node::Leaf(_) | Node::Stack { .. } => {} + Node::Split { id, ratio, a, b, .. } => { + if let Some(updated) = ratios.get(id) { + *ratio = *updated; + } + a.set_split_ratios(ratios); + b.set_split_ratios(ratios); + } + } + } } #[cfg(test)] @@ -384,6 +535,48 @@ mod tests { Node::Stack { ref panes, expanded: 1 } if panes.as_slice() == [1] )); } + + #[test] + fn legacy_projection_preserves_many_equal_viewport_column_widths() { + let mut screen = Screen { + id: 1, + name: None, + root: Node::Leaf(1), + active_pane: 21, + zoomed_pane: None, + zellij_auto_layout: None, + viewport_splits: BTreeMap::new(), + viewport_base_width: None, + layout_columns: (1..=21) + .map(|pane| LayoutColumn { + id: 100 + pane, + width: 1.0, + root: Node::Leaf(pane), + zellij_auto_layout: None, + }) + .collect(), + layout_revision: 0, + layout_undo: VecDeque::new(), + }; + + screen.sync_layout_column_projection(); + + let layout = crate::layout::layout_screen( + &screen.root, + crate::layout::Rect { x: 0, y: 0, width: 2_100, height: 24 }, + Some(screen.active_pane), + ); + assert_eq!(layout.panes.len(), 21); + assert!( + layout.panes.iter().all(|(_, rect)| rect.width == 100), + "legacy clients should receive the authoritative equal-width projection: {:?}", + layout.panes + ); + let Node::Split { ratio, .. } = screen.root else { + panic!("multiple columns should produce a compatibility split"); + }; + assert!((ratio - (20.0 / 21.0)).abs() < f32::EPSILON); + } } /// A split-tree leaf: an ordered list of tabs (surfaces) with one active. @@ -418,6 +611,299 @@ pub struct Screen { /// Stable pane creation order for Zellij's default auto-layout family. /// `None` means the screen owns a custom/damaged layout. pub zellij_auto_layout: Option>, + /// Horizontal splits created as viewport columns. The value is the + /// right-hand column width as a fraction of the frontend viewport. + pub viewport_splits: BTreeMap, + /// Width of the first viewport column. `None` when horizontal viewport + /// layout has not been activated for this screen. + pub viewport_base_width: Option, + /// Stable horizontal layout containers. Empty means the screen follows + /// the ordinary single-tree behavior. `root`, `viewport_splits`, and + /// `viewport_base_width` are derived compatibility projections while + /// this list is non-empty. + pub(crate) layout_columns: Vec, + /// Monotonic structural revision used to fence layout undo. + pub(crate) layout_revision: u64, + /// In-memory only. Pane processes cannot be resurrected across daemon + /// restarts, so persisting this history would create unsafe expectations. + pub(crate) layout_undo: VecDeque, +} + +impl Screen { + pub(crate) fn layout_snapshot(&self) -> ScreenLayoutSnapshot { + ScreenLayoutSnapshot { + root: self.root.clone(), + active_pane: self.active_pane, + zoomed_pane: self.zoomed_pane, + zellij_auto_layout: self.zellij_auto_layout.clone(), + viewport_splits: self.viewport_splits.clone(), + viewport_base_width: self.viewport_base_width, + layout_columns: self.layout_columns.clone(), + } + } + + fn coalesces_layout_change(&self, key: LayoutMutationKey) -> bool { + self.layout_undo.back().is_some_and(|entry| { + entry.created_panes.is_empty() + && entry.coalesce == Some(key) + && entry.after_revision == self.layout_revision + }) + } + + pub(crate) fn layout_snapshot_for_coalescing_change( + &self, + key: Option, + ) -> Option { + if key.is_some_and(|key| self.coalesces_layout_change(key)) { + None + } else { + Some(self.layout_snapshot()) + } + } + + pub(crate) fn record_layout_change( + &mut self, + before: ScreenLayoutSnapshot, + created_panes: Vec, + coalesce: Option, + ) { + self.record_prepared_layout_change(Some(before), created_panes, coalesce); + } + + pub(crate) fn record_prepared_layout_change( + &mut self, + before: Option, + created_panes: Vec, + coalesce: Option, + ) { + self.layout_revision = self.layout_revision.saturating_add(1); + if created_panes.is_empty() + && coalesce.is_some() + && self.layout_undo.back().is_some_and(|entry| { + entry.created_panes.is_empty() + && entry.coalesce == coalesce + && entry.after_revision.saturating_add(1) == self.layout_revision + }) + { + if let Some(entry) = self.layout_undo.back_mut() { + entry.after_revision = self.layout_revision; + entry.confirmation = None; + } + return; + } + let before = before.expect("non-coalesced layout changes require a prior snapshot"); + self.layout_undo.push_back(LayoutUndoEntry { + before, + after_revision: self.layout_revision, + created_panes, + coalesce, + confirmation: None, + }); + while self.layout_undo.len() > LAYOUT_UNDO_LIMIT { + self.layout_undo.pop_front(); + } + } + + pub(crate) fn invalidate_layout_undo(&mut self) { + self.layout_revision = self.layout_revision.saturating_add(1); + self.layout_undo.clear(); + } + + pub(crate) fn restore_layout_snapshot(&mut self, mut snapshot: ScreenLayoutSnapshot) { + let active_pane = self.active_pane; + // Focus changes are not structural layout transactions. Preserve their + // per-stack selection when an undo restores the same pane membership. + let mut stack_expansions = BTreeMap::new(); + if self.layout_columns_active() { + for column in &self.layout_columns { + column.root.collect_stack_expansions(&mut stack_expansions); + } + } else { + self.root.collect_stack_expansions(&mut stack_expansions); + } + snapshot.root.restore_stack_expansions(&stack_expansions); + for column in &mut snapshot.layout_columns { + column.root.restore_stack_expansions(&stack_expansions); + } + self.root = snapshot.root; + self.zoomed_pane = snapshot.zoomed_pane.filter(|pane| self.root.contains(*pane)); + self.active_pane = self.zoomed_pane.unwrap_or_else(|| { + if self.root.contains(active_pane) { active_pane } else { snapshot.active_pane } + }); + self.zellij_auto_layout = snapshot.zellij_auto_layout; + self.viewport_splits = snapshot.viewport_splits; + self.viewport_base_width = snapshot.viewport_base_width; + self.layout_columns = snapshot.layout_columns; + } + + pub(crate) fn layout_columns_active(&self) -> bool { + !self.layout_columns.is_empty() + } + + pub(crate) fn is_projected_viewport_split(&self, split: SplitId) -> bool { + self.layout_columns.iter().skip(1).any(|column| column.id == split) + } + + pub(crate) fn layout_column_for_pane_mut(&mut self, pane: PaneId) -> Option<&mut LayoutColumn> { + self.layout_columns.iter_mut().find(|column| column.root.contains(pane)) + } + + pub(crate) fn insert_layout_column_after( + &mut self, + target: PaneId, + base_id: SplitId, + column: LayoutColumn, + ) -> bool { + if self.layout_columns.is_empty() { + if !self.root.contains(target) { + return false; + } + let root = std::mem::replace(&mut self.root, Node::Leaf(0)); + self.layout_columns.push(LayoutColumn { + id: base_id, + width: self.viewport_base_width.unwrap_or(1.0), + root, + zellij_auto_layout: self.zellij_auto_layout.take(), + }); + } + let Some(index) = + self.layout_columns.iter().position(|candidate| candidate.root.contains(target)) + else { + return false; + }; + self.layout_columns.insert(index + 1, column); + self.sync_layout_column_projection(); + true + } + + pub(crate) fn sync_layout_column_projection(&mut self) { + let Some(first) = self.layout_columns.first() else { + self.viewport_splits.clear(); + self.viewport_base_width = None; + return; + }; + self.viewport_splits.clear(); + self.viewport_base_width = Some(first.width); + self.zellij_auto_layout = None; + + let mut root = first.root.clone(); + let mut width_before = first.width; + for column in self.layout_columns.iter().skip(1) { + // This tree is a read-compatibility projection, not a user resize + // request. Preserve exact authoritative proportions even when a + // wide layout requires a derived ratio outside mutation bounds. + let ratio = width_before / (width_before + column.width); + root = Node::Split { + id: column.id, + dir: SplitDir::Right, + ratio, + a: Box::new(root), + b: Box::new(column.root.clone()), + }; + self.viewport_splits.insert(column.id, column.width); + width_before += column.width; + } + self.root = root; + debug_assert!(self.layout_column_projection_is_consistent()); + } + + pub(crate) fn sync_layout_column_width_projection(&mut self) { + let Some(first) = self.layout_columns.first() else { + self.viewport_splits.clear(); + self.viewport_base_width = None; + return; + }; + self.viewport_splits.clear(); + self.viewport_base_width = Some(first.width); + let mut ratios = BTreeMap::new(); + let mut width_before = first.width; + for column in self.layout_columns.iter().skip(1) { + ratios.insert(column.id, width_before / (width_before + column.width)); + self.viewport_splits.insert(column.id, column.width); + width_before += column.width; + } + self.root.set_split_ratios(&ratios); + debug_assert!(self.layout_column_projection_is_consistent()); + } + + /// Apply a compatibility split ratio to an authoritative viewport column. + /// + /// Projected split `i` represents all preceding columns on the left and + /// column `i` on the right, so changing its ratio changes that right + /// column's frontend-relative width. + pub(crate) fn set_projected_viewport_split_ratio( + &mut self, + split: SplitId, + ratio: f32, + ) -> ProjectedSplitRatioUpdate { + let Some(index) = self + .layout_columns + .iter() + .position(|column| column.id == split) + .filter(|index| *index > 0) + else { + return ProjectedSplitRatioUpdate::NotProjected; + }; + let width_before = + self.layout_columns[..index].iter().map(|column| column.width).sum::(); + let width = width_before * (1.0 - ratio) / ratio; + if !width.is_finite() + || !(crate::MIN_VIEWPORT_PANE_WIDTH..=crate::MAX_VIEWPORT_PANE_WIDTH).contains(&width) + { + return ProjectedSplitRatioUpdate::Unrepresentable { width }; + } + if self.layout_columns[index].width == width { + return ProjectedSplitRatioUpdate::Unchanged; + } + self.layout_columns[index].width = width; + self.sync_layout_column_width_projection(); + ProjectedSplitRatioUpdate::Applied + } + + pub(crate) fn collapse_single_layout_column(&mut self) { + if self.layout_columns.len() != 1 { + self.sync_layout_column_projection(); + return; + } + let column = self.layout_columns.pop().expect("single layout column"); + self.root = column.root; + self.zellij_auto_layout = column.zellij_auto_layout; + self.viewport_splits.clear(); + self.viewport_base_width = None; + } + + pub(crate) fn layout_column_projection_is_consistent(&self) -> bool { + if self.layout_columns.is_empty() { + return self.viewport_splits.is_empty() && self.viewport_base_width.is_none(); + } + if self.layout_columns.len() < 2 + || self.zellij_auto_layout.is_some() + || self.viewport_base_width != self.layout_columns.first().map(|column| column.width) + || self.viewport_splits.len() + 1 != self.layout_columns.len() + { + return false; + } + + let projected_panes = self.root.pane_ids_vec(); + let column_panes = self + .layout_columns + .iter() + .flat_map(|column| column.root.pane_ids_vec()) + .collect::>(); + if projected_panes != column_panes { + return false; + } + + self.layout_columns.iter().enumerate().all(|(index, column)| { + let expected_owner = + if index == 0 { ViewportColumn::Base } else { ViewportColumn::Split(column.id) }; + (index == 0 || self.viewport_splits.get(&column.id).copied() == Some(column.width)) + && column.root.pane_ids_vec().into_iter().all(|pane| { + self.root.viewport_column_owner(pane, &self.viewport_splits) + == Some(expected_owner) + }) + }) + } } #[derive(Debug)] diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index c87c0ce618c1..ffd2c274f1d6 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -16,8 +16,17 @@ use zeroize::Zeroize; use crate::browser::{self, BrowserBootstrap, BrowserRuntime}; use crate::event_bus::{MuxEventBroadcaster, MuxEventReceiver}; -use crate::layout::{Rect, layout_screen}; -use crate::model::{Node, Pane, Screen, State, Workspace}; +#[cfg(test)] +use crate::layout::layout_screen_with_viewport; +use crate::layout::{ + LayoutResult, MAX_VIEWPORT_PANE_WIDTH, MIN_VIEWPORT_PANE_WIDTH, Rect, layout_screen, +}; +#[cfg(test)] +use crate::model::ViewportColumn; +use crate::model::{ + LayoutColumn, LayoutMutationKey, LayoutResizeOwner, LayoutUndoConfirmation, Node, Pane, + ProjectedSplitRatioUpdate, Screen, State, Workspace, +}; use crate::pairing::PairingBroker; use crate::surface::{DefaultColors, Surface, SurfaceOptions}; use crate::terminal_host::TerminalId; @@ -544,6 +553,108 @@ pub struct ZoomState { pub zoomed_pane: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LayoutUndoResult { + Undone { screen: ScreenId, revision: u64 }, + ConfirmationRequired { screen: ScreenId, revision: u64, closes_panes: Vec }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LayoutUndoError { + Unavailable, + Stale(String), +} + +impl LayoutUndoError { + pub const UNAVAILABLE_CODE: &'static str = "layout-undo-unavailable"; + pub const STALE_CODE: &'static str = "layout-undo-stale"; + + pub fn code(&self) -> &'static str { + match self { + Self::Unavailable => Self::UNAVAILABLE_CODE, + Self::Stale(_) => Self::STALE_CODE, + } + } +} + +impl fmt::Display for LayoutUndoError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unavailable => formatter.write_str("no layout change to undo"), + Self::Stale(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for LayoutUndoError {} + +#[derive(Debug, Clone, PartialEq)] +pub enum LayoutRatioError { + UnknownPaneSplit { pane: PaneId }, + UnknownSplit { split: SplitId }, + UnrepresentableViewportWidth { split: SplitId, ratio: f32, width: f32 }, +} + +impl LayoutRatioError { + pub const UNKNOWN_TARGET_CODE: &'static str = "layout-ratio-target-missing"; + pub const OUT_OF_RANGE_CODE: &'static str = "layout-ratio-out-of-range"; + + pub fn code(&self) -> &'static str { + match self { + Self::UnknownPaneSplit { .. } | Self::UnknownSplit { .. } => Self::UNKNOWN_TARGET_CODE, + Self::UnrepresentableViewportWidth { .. } => Self::OUT_OF_RANGE_CODE, + } + } +} + +impl fmt::Display for LayoutRatioError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownPaneSplit { pane } => write!(formatter, "unknown pane/split {pane}"), + Self::UnknownSplit { split } => write!(formatter, "unknown split {split}"), + Self::UnrepresentableViewportWidth { split, ratio, width } => write!( + formatter, + "split {split} ratio {ratio} implies viewport width {width}; width must be between {MIN_VIEWPORT_PANE_WIDTH} and {MAX_VIEWPORT_PANE_WIDTH}" + ), + } + } +} + +impl std::error::Error for LayoutRatioError {} + +#[derive(Debug, Clone, PartialEq)] +pub enum ViewportWidthError { + OutOfRange { width: f32 }, + PaneNotResizable { pane: PaneId }, +} + +impl ViewportWidthError { + pub const OUT_OF_RANGE_CODE: &'static str = "viewport-width-out-of-range"; + pub const COLUMN_MISSING_CODE: &'static str = "viewport-column-not-found"; + + pub fn code(&self) -> &'static str { + match self { + Self::OutOfRange { .. } => Self::OUT_OF_RANGE_CODE, + Self::PaneNotResizable { .. } => Self::COLUMN_MISSING_CODE, + } + } +} + +impl fmt::Display for ViewportWidthError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OutOfRange { .. } => { + formatter.write_str("viewport pane width must be between 0.1 and 1.0") + } + Self::PaneNotResizable { pane } => { + write!(formatter, "pane {pane} has no resizable viewport column") + } + } + } +} + +impl std::error::Error for ViewportWidthError {} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SidebarPluginOptions { pub command: Vec, @@ -778,6 +889,7 @@ pub struct Mux { next_id: AtomicU64, next_notification_id: AtomicU64, next_active_at: AtomicU64, + next_in_process_resize_owner: AtomicU64, surface_options: Mutex, provider_workspace: Mutex, workspace_lifecycles: Mutex>>>, @@ -802,6 +914,8 @@ pub struct Mux { terminal_create_after_materialization_lock: Mutex>>, #[cfg(test)] terminal_create_after_workspace_reservation: Mutex>>, + #[cfg(test)] + viewport_split_after_spawn: Mutex>>, browser_runtime: Mutex>>, cell_pixels: Mutex<(u16, u16)>, default_colors: Mutex, @@ -1003,6 +1117,7 @@ impl Mux { next_id: AtomicU64::new(next_id), next_notification_id: AtomicU64::new(1), next_active_at: AtomicU64::new(1), + next_in_process_resize_owner: AtomicU64::new(1), surface_options: Mutex::new(surface_options), provider_workspace: Mutex::new(provider_workspace), workspace_lifecycles: Mutex::new(HashMap::new()), @@ -1027,6 +1142,8 @@ impl Mux { terminal_create_after_materialization_lock: Mutex::new(None), #[cfg(test)] terminal_create_after_workspace_reservation: Mutex::new(None), + #[cfg(test)] + viewport_split_after_spawn: Mutex::new(None), browser_runtime: Mutex::new(None), cell_pixels: Mutex::new((8, 16)), default_colors: Mutex::new(DefaultColors::default()), @@ -1464,6 +1581,11 @@ impl Mux { active_pane: pane_id, zoomed_pane: None, zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }); workspace.active_screen = workspace.screens.len() - 1; } @@ -1684,6 +1806,18 @@ impl Mux { self.next_notification_id.fetch_add(1, Ordering::Relaxed) } + /// Allocate an undo-coalescing owner for one in-process frontend. + /// + /// Layout undo is in-memory state, so this namespace intentionally follows + /// the mux lifecycle rather than durable workspace identity. + pub fn allocate_in_process_resize_owner(&self) -> u64 { + self.next_in_process_resize_owner + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |owner| { + Some(owner.wrapping_add(1).max(1)) + }) + .expect("in-process resize owner allocation cannot fail") + } + fn new_workspace_key() -> anyhow::Result { let mut bytes = [0u8; 16]; getrandom::fill(&mut bytes).map_err(|_| { @@ -2077,6 +2211,11 @@ impl Mux { let mut index = HashMap::new(); for (workspace_index, workspace) in state.workspaces.iter().enumerate() { for (screen_index, screen) in workspace.screens.iter().enumerate() { + debug_assert!( + screen.layout_column_projection_is_consistent(), + "screen {} has a stale layout column projection", + screen.id + ); index_node(&screen.root, workspace_index, screen_index, screen.id, &mut index); } } @@ -4270,6 +4409,11 @@ impl Mux { active_pane: pane_id, zoomed_pane: None, zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }); ws.active_screen = ws.screens.len() - 1; let workspace = ws.id; @@ -4675,6 +4819,11 @@ impl Mux { active_pane: pane_id, zoomed_pane: None, zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }); state.workspaces[wi].active_screen = 0; let entity = crate::server::tree_entity_json( @@ -4803,6 +4952,11 @@ impl Mux { active_pane: pane_id, zoomed_pane: None, zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }); state.workspaces[workspace_index].active_screen = 0; let entity = crate::server::tree_entity_json( @@ -4880,6 +5034,11 @@ impl Mux { active_pane: pane_id, zoomed_pane: None, zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }], active_screen: 0, }); @@ -5028,6 +5187,11 @@ impl Mux { active_pane: pane_id, zoomed_pane: None, zellij_auto_layout: Some(vec![pane_id]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }); state.workspaces[wi].active_screen = 0; let entity = crate::server::tree_entity_json( @@ -5165,14 +5329,57 @@ impl Mux { target: PaneId, dir: SplitDir, size: Option<(u16, u16)>, + ) -> anyhow::Result> { + self.split_with_viewport_width(target, dir, size, None) + } + + /// Add a terminal as a viewport-width column after the target's column. + /// + /// Updated frontends render the new column at `width` times their own + /// viewport width. The ordinary split ratio remains valid fallback data + /// for older clients. + pub fn new_pane_right( + self: &Arc, + target: PaneId, + width: f32, + size: Option<(u16, u16)>, + ) -> anyhow::Result> { + if !width.is_finite() + || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) + { + return Err(ViewportWidthError::OutOfRange { width }.into()); + } + self.split_with_viewport_width(target, SplitDir::Right, size, Some(width)) + } + + fn split_with_viewport_width( + self: &Arc, + target: PaneId, + dir: SplitDir, + size: Option<(u16, u16)>, + viewport_width: Option, ) -> anyhow::Result> { let cwd = self.pane_cwd(target); let workspace_key = self .workspace_key_for_pane(target) .ok_or_else(|| anyhow::anyhow!("pane {target} has no workspace"))?; - let surface = self.spawn_surface_in_workspace(&workspace_key, cwd, size, None)?; + let surface = match self.spawn_surface_in_workspace(&workspace_key, cwd, size, None) { + Ok(surface) => surface, + Err(error) if viewport_width.is_some() => { + eprintln!("cmux-tui: viewport pane PTY creation failed: {error:#}"); + return Err(anyhow::anyhow!("pane creation failed")); + } + Err(error) => return Err(error), + }; + #[cfg(test)] + if viewport_width.is_some() + && let Some(hook) = self.viewport_split_after_spawn.lock().unwrap().clone() + { + hook(); + } let pane_id = self.next_id(); let split_id = self.next_id(); + let base_column_id = viewport_width.map(|_| self.next_id()); let active_at = self.next_active_at(); let mut done = false; let mut changed_screen = None; @@ -5183,13 +5390,46 @@ impl Mux { let mut state = self.state.lock().unwrap(); 'outer: for ws in state.workspaces.iter_mut() { for screen in ws.screens.iter_mut() { - if screen.root.split_leaf(target, split_id, dir, pane_id) { + if !screen.root.contains(target) { + continue; + } + let before = screen.layout_snapshot(); + let split = if let Some(width) = viewport_width { + screen.insert_layout_column_after( + target, + base_column_id.expect("viewport column has a base id"), + LayoutColumn { + id: split_id, + width, + root: Node::Leaf(pane_id), + zellij_auto_layout: Some(vec![pane_id]), + }, + ) + } else if screen.layout_columns_active() { + let inserted = + screen.layout_column_for_pane_mut(target).is_some_and(|column| { + let inserted = + column.root.split_leaf(target, split_id, dir, pane_id); + if inserted { + column.zellij_auto_layout = None; + } + inserted + }); + if inserted { + screen.sync_layout_column_projection(); + } + inserted + } else { + let inserted = screen.root.split_leaf(target, split_id, dir, pane_id); + if inserted { + screen.zellij_auto_layout = None; + } + inserted + }; + if split { screen.active_pane = pane_id; - // A directional split damages the automatic layout. - // The next Alt-N can establish a fresh Zellij layout - // from stable pane ids, but close must preserve this - // manual tree until then. - screen.zellij_auto_layout = None; + screen.zoomed_pane = None; + screen.record_layout_change(before, vec![pane_id], None); changed_screen = Some(screen.id); changed_workspace = Some(ws.id); done = true; @@ -5230,12 +5470,19 @@ impl Mux { } } if !done { - self.fail_hosted_terminal_attachment( - &surface, - "terminal-split-attach-failed", - "pane-disappeared-before-attach", - )?; - anyhow::bail!("pane {target} not found"); + if viewport_width.is_none() { + self.fail_hosted_terminal_attachment( + &surface, + "terminal-split-attach-failed", + "pane-disappeared-before-attach", + )?; + anyhow::bail!("pane {target} not found"); + } + // Registry close failure must not leave an unbound Running + // terminal. The shared discard path restores every canonical + // terminal to its registry workspace when close cannot commit. + self.discard_spawned(vec![surface]); + anyhow::bail!("pane creation failed"); } self.emit(MuxEvent::TreeDelta(delta.expect("successful split has a tree delta"))); if let Some(screen) = changed_screen { @@ -5279,25 +5526,29 @@ impl Mux { if !screen.root.contains(target) { continue; } - let mut panes = screen.zellij_auto_layout.clone().unwrap_or_else(|| { - let mut panes = Vec::new(); - screen.root.pane_ids(&mut panes); - panes.sort_unstable(); - panes - }); - let mut current_panes = Vec::new(); - screen.root.pane_ids(&mut current_panes); - let current_panes = current_panes.into_iter().collect::>(); - panes.retain(|pane| current_panes.contains(pane)); - panes.push(pane_id); - screen.root = - crate::layout::zellij_default_pane_layout_with_ids(&panes, &mut || { - self.next_id() - }) - .expect("new pane layout always has at least one pane"); + let before = screen.layout_snapshot(); + if screen.layout_columns_active() { + let column = screen + .layout_column_for_pane_mut(target) + .expect("screen containing target has a column"); + append_to_auto_layout( + &mut column.root, + &mut column.zellij_auto_layout, + pane_id, + || self.next_id(), + ); + screen.sync_layout_column_projection(); + } else { + append_to_auto_layout( + &mut screen.root, + &mut screen.zellij_auto_layout, + pane_id, + || self.next_id(), + ); + } screen.active_pane = pane_id; screen.zoomed_pane = None; - screen.zellij_auto_layout = Some(panes); + screen.record_layout_change(before, vec![pane_id], None); changed_screen = Some(screen.id); changed_workspace = Some(ws.id); break 'outer; @@ -6216,8 +6467,19 @@ impl Mux { && (screen.root.contains_stack_pane(previous) || screen.root.contains_stack_pane(pane))) .then_some(screen.id); - screen.root.expand_stack_pane(previous); - screen.root.expand_stack_pane(pane); + if screen.layout_columns_active() { + let mut expanded = false; + for column in &mut screen.layout_columns { + expanded |= column.root.expand_stack_pane(previous); + expanded |= column.root.expand_stack_pane(pane); + } + if expanded { + screen.sync_layout_column_projection(); + } + } else { + screen.root.expand_stack_pane(previous); + screen.root.expand_stack_pane(pane); + } screen.active_pane = pane; stamp_pane_focus(self, &mut state, pane); (true, Self::active_surface_in_state(&state), layout_changed) @@ -6238,61 +6500,363 @@ impl Mux { /// Set the deepest split ratio in `dir` on the path to `pane`. pub fn set_ratio(&self, pane: PaneId, dir: SplitDir, ratio: f32) -> bool { + self.set_ratio_checked(pane, dir, ratio).is_ok() + } + + /// Set a pane-addressed split ratio while preserving rejection details. + pub fn set_ratio_checked( + &self, + pane: PaneId, + dir: SplitDir, + ratio: f32, + ) -> Result<(), LayoutRatioError> { + enum RatioOutcome { + Unchanged, + Changed(ScreenId), + Failed(LayoutRatioError), + } + let ratio = clamp_split_ratio(ratio); - let changed_screen = { + let outcome = { let mut state = self.state.lock().unwrap(); state.workspaces.iter_mut().flat_map(|ws| ws.screens.iter_mut()).find_map(|screen| { - if screen.root.set_deepest_ratio(pane, dir, ratio) { - screen.zellij_auto_layout = None; - Some(screen.id) + if !screen.root.contains(pane) { + return None; + } + let split = screen.root.deepest_split_for_pane(pane, dir)?; + if !screen.is_projected_viewport_split(split) + && screen.root.split_ratio(split) == Some(ratio) + { + return Some(RatioOutcome::Unchanged); + } + let before = screen.layout_snapshot(); + let changed = if screen.layout_columns_active() { + match screen.set_projected_viewport_split_ratio(split, ratio) { + ProjectedSplitRatioUpdate::Applied => true, + ProjectedSplitRatioUpdate::Unchanged => { + return Some(RatioOutcome::Unchanged); + } + ProjectedSplitRatioUpdate::Unrepresentable { width } => { + return Some(RatioOutcome::Failed( + LayoutRatioError::UnrepresentableViewportWidth { + split, + ratio, + width, + }, + )); + } + ProjectedSplitRatioUpdate::NotProjected => { + let changed = screen.layout_columns.iter_mut().any(|column| { + let changed = column.root.set_split_ratio(split, ratio); + if changed { + column.zellij_auto_layout = None; + } + changed + }); + if changed { + screen.sync_layout_column_projection(); + } + changed + } + } + } else { + let changed = screen.root.set_deepest_ratio(pane, dir, ratio); + if changed { + screen.zellij_auto_layout = None; + } + changed + }; + if changed { + screen.record_layout_change(before, Vec::new(), None); + Some(RatioOutcome::Changed(screen.id)) } else { None } }) }; - if let Some(screen) = changed_screen { - self.emit(MuxEvent::TreeChanged); - self.emit(MuxEvent::LayoutChanged(screen)); - true - } else { - false + match outcome { + Some(RatioOutcome::Changed(screen)) => { + self.emit(MuxEvent::TreeChanged); + self.emit(MuxEvent::LayoutChanged(screen)); + Ok(()) + } + Some(RatioOutcome::Unchanged) => Ok(()), + Some(RatioOutcome::Failed(error)) => Err(error), + None => Err(LayoutRatioError::UnknownPaneSplit { pane }), } } /// Set one split ratio by its stable split-tree node id. pub fn set_split_ratio(&self, split: SplitId, ratio: f32) -> bool { + self.set_split_ratio_checked(split, ratio).is_ok() + } + + /// Set one split ratio while preserving rejection details. + pub fn set_split_ratio_checked( + &self, + split: SplitId, + ratio: f32, + ) -> Result<(), LayoutRatioError> { + self.set_split_ratio_inner(split, ratio, None) + } + + /// Set one split ratio as part of a client-scoped resize transaction. + pub fn set_split_ratio_in_transaction( + &self, + split: SplitId, + ratio: f32, + client: u64, + transaction: u64, + ) -> bool { + self.set_split_ratio_in_transaction_checked(split, ratio, client, transaction).is_ok() + } + + /// Set one transactional split ratio while preserving rejection details. + pub fn set_split_ratio_in_transaction_checked( + &self, + split: SplitId, + ratio: f32, + client: u64, + transaction: u64, + ) -> Result<(), LayoutRatioError> { + self.set_split_ratio_inner( + split, + ratio, + Some((LayoutResizeOwner::ControlClient(client), transaction)), + ) + } + + /// Set one in-process transactional split ratio without sharing the + /// control-client ownership namespace. + pub fn set_split_ratio_in_process_transaction_checked( + &self, + split: SplitId, + ratio: f32, + owner: u64, + transaction: u64, + ) -> Result<(), LayoutRatioError> { + self.set_split_ratio_inner( + split, + ratio, + Some((LayoutResizeOwner::InProcess(owner), transaction)), + ) + } + + fn set_split_ratio_inner( + &self, + split: SplitId, + ratio: f32, + transaction: Option<(LayoutResizeOwner, u64)>, + ) -> Result<(), LayoutRatioError> { let ratio = clamp_split_ratio(ratio); let changed_screen = { let mut state = self.state.lock().unwrap(); let Some((workspace_index, screen_index, owner)) = state.split_screens.get(&split).copied() else { - return false; + return Err(LayoutRatioError::UnknownSplit { split }); }; - let changed = state + if state .workspaces - .get_mut(workspace_index) - .and_then(|workspace| workspace.screens.get_mut(screen_index)) - .filter(|screen| screen.id == owner) - .and_then(|screen| { - if screen.root.set_split_ratio(split, ratio) { - screen.zellij_auto_layout = None; - Some(screen.id) - } else { - None + .get(workspace_index) + .and_then(|workspace| workspace.screens.get(screen_index)) + .is_none_or(|screen| screen.id != owner) + { + state.split_screens.remove(&split); + return Err(LayoutRatioError::UnknownSplit { split }); + } + let screen = &mut state.workspaces[workspace_index].screens[screen_index]; + if !screen.is_projected_viewport_split(split) + && screen.root.split_ratio(split) == Some(ratio) + { + return Ok(()); + } + let coalesce = transaction + .map(|(owner, transaction)| LayoutMutationKey::Resize { owner, transaction }); + let before = screen.layout_snapshot_for_coalescing_change(coalesce); + let changed = if screen.layout_columns_active() { + match screen.set_projected_viewport_split_ratio(split, ratio) { + ProjectedSplitRatioUpdate::Applied => true, + ProjectedSplitRatioUpdate::Unchanged => return Ok(()), + ProjectedSplitRatioUpdate::Unrepresentable { width } => { + return Err(LayoutRatioError::UnrepresentableViewportWidth { + split, + ratio, + width, + }); } - }); - if changed.is_none() { + ProjectedSplitRatioUpdate::NotProjected => { + let changed = screen.layout_columns.iter_mut().any(|column| { + let changed = column.root.set_split_ratio(split, ratio); + if changed { + column.zellij_auto_layout = None; + } + changed + }); + if changed { + let projection_changed = screen.root.set_split_ratio(split, ratio); + debug_assert!(projection_changed); + } + changed + } + } + } else { + let changed = screen.root.set_split_ratio(split, ratio); + if changed { + screen.zellij_auto_layout = None; + } + changed + }; + if !changed { state.split_screens.remove(&split); + return Err(LayoutRatioError::UnknownSplit { split }); } - changed + screen.record_prepared_layout_change(before, Vec::new(), coalesce); + screen.id }; - if let Some(screen) = changed_screen { - self.emit(MuxEvent::LayoutChanged(screen)); - true + self.emit(MuxEvent::LayoutChanged(changed_screen)); + Ok(()) + } + + /// Set the width of the horizontal viewport column containing `pane`. + pub fn set_viewport_pane_width(&self, pane: PaneId, width: f32) -> bool { + self.set_viewport_pane_width_checked(pane, width).is_ok() + } + + /// Set a viewport column width while preserving rejection details. + pub fn set_viewport_pane_width_checked( + &self, + pane: PaneId, + width: f32, + ) -> Result<(), ViewportWidthError> { + self.set_viewport_pane_width_inner(pane, width, None) + } + + /// Set a viewport column width as part of a client-scoped resize transaction. + pub fn set_viewport_pane_width_in_transaction( + &self, + pane: PaneId, + width: f32, + client: u64, + transaction: u64, + ) -> bool { + self.set_viewport_pane_width_in_transaction_checked(pane, width, client, transaction) + .is_ok() + } + + /// Set a transactional viewport width while preserving rejection details. + pub fn set_viewport_pane_width_in_transaction_checked( + &self, + pane: PaneId, + width: f32, + client: u64, + transaction: u64, + ) -> Result<(), ViewportWidthError> { + self.set_viewport_pane_width_inner( + pane, + width, + Some((LayoutResizeOwner::ControlClient(client), transaction)), + ) + } + + /// Set one in-process transactional viewport width without sharing the + /// control-client ownership namespace. + pub fn set_viewport_pane_width_in_process_transaction_checked( + &self, + pane: PaneId, + width: f32, + owner: u64, + transaction: u64, + ) -> Result<(), ViewportWidthError> { + self.set_viewport_pane_width_inner( + pane, + width, + Some((LayoutResizeOwner::InProcess(owner), transaction)), + ) + } + + fn set_viewport_pane_width_inner( + &self, + pane: PaneId, + width: f32, + transaction: Option<(LayoutResizeOwner, u64)>, + ) -> Result<(), ViewportWidthError> { + if !width.is_finite() + || !(MIN_VIEWPORT_PANE_WIDTH..=MAX_VIEWPORT_PANE_WIDTH).contains(&width) + { + return Err(ViewportWidthError::OutOfRange { width }); + } + let changed_screen = { + let mut state = self.state.lock().unwrap(); + let Some((workspace_index, screen_index)) = state.screen_of(pane) else { + return Err(ViewportWidthError::PaneNotResizable { pane }); + }; + let screen = &mut state.workspaces[workspace_index].screens[screen_index]; + if !screen.layout_columns_active() { + return Err(ViewportWidthError::PaneNotResizable { pane }); + } + let Some(column_index) = + screen.layout_columns.iter().position(|column| column.root.contains(pane)) + else { + return Err(ViewportWidthError::PaneNotResizable { pane }); + }; + if (screen.layout_columns[column_index].width - width).abs() < f32::EPSILON { + return Ok(()); + } + let coalesce = transaction + .map(|(owner, transaction)| LayoutMutationKey::Resize { owner, transaction }); + let before = screen.layout_snapshot_for_coalescing_change(coalesce); + screen.layout_columns[column_index].width = width; + screen.sync_layout_column_width_projection(); + screen.record_prepared_layout_change(before, Vec::new(), coalesce); + screen.id + }; + self.emit(MuxEvent::LayoutChanged(changed_screen)); + Ok(()) + } + + fn pane_navigation_layout(screen: &Screen, pane: PaneId, dir: Direction) -> LayoutResult { + const NAVIGATION_COLUMN_WIDTH: u16 = 10_000; + let column_area = Rect { x: 0, y: 0, width: NAVIGATION_COLUMN_WIDTH, height: 10_000 }; + if !screen.layout_columns_active() { + return layout_screen(&screen.root, column_area, Some(screen.active_pane)); + } + let Some(current_index) = + screen.layout_columns.iter().position(|column| column.root.contains(pane)) + else { + return layout_screen(&screen.root, column_area, Some(screen.active_pane)); + }; + let neighbor_index = match dir { + Direction::Up | Direction::Down => None, + Direction::Left => current_index.checked_sub(1), + Direction::Right => { + current_index.checked_add(1).filter(|index| *index < screen.layout_columns.len()) + } + }; + let Some(neighbor_index) = neighbor_index else { + return layout_screen( + &screen.layout_columns[current_index].root, + column_area, + Some(screen.active_pane), + ); + }; + + let (left_index, right_index) = if neighbor_index < current_index { + (neighbor_index, current_index) } else { - false + (current_index, neighbor_index) + }; + let mut result = LayoutResult { virtual_width: 20_000, ..Default::default() }; + for (index, x) in [(left_index, 0), (right_index, NAVIGATION_COLUMN_WIDTH)] { + let mut column = layout_screen( + &screen.layout_columns[index].root, + Rect { x, ..column_area }, + Some(screen.active_pane), + ); + result.panes.append(&mut column.panes); + result.stacked_headers.extend(column.stacked_headers); } + result } pub fn pane_neighbor(&self, pane: PaneId, dir: Direction) -> anyhow::Result> { @@ -6302,11 +6866,7 @@ impl Mux { }; let screen = &state.workspaces[wi].screens[si]; let (dx, dy) = dir.delta(); - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 10_000, height: 10_000 }, - Some(screen.active_pane), - ); + let layout = Self::pane_navigation_layout(screen, pane, dir); Ok(layout.neighbor(pane, dx, dy)) }) } @@ -6318,11 +6878,7 @@ impl Mux { }; let screen = &state.workspaces[wi].screens[si]; let (dx, dy) = dir.delta(); - let layout = layout_screen( - &screen.root, - Rect { x: 0, y: 0, width: 10_000, height: 10_000 }, - Some(screen.active_pane), - ); + let layout = Self::pane_navigation_layout(screen, pane, dir); Ok(layout.neighbor_by_recency(pane, dx, dy, |candidate| { state.panes.get(&candidate).map(|pane| pane.focused_at).unwrap_or_default() })) @@ -6348,19 +6904,45 @@ impl Mux { } pub fn swap_panes(&self, pane: PaneId, target: PaneId) -> bool { + if pane == target { + return false; + } let changed_screen = { let mut state = self.state.lock().unwrap(); state.workspaces.iter_mut().flat_map(|ws| ws.screens.iter_mut()).find_map(|screen| { - if screen.root.swap_leaves(pane, target) { - screen.zellij_auto_layout = None; - Some(screen.id) - } else { - None + if !screen.root.contains(pane) || !screen.root.contains(target) { + return None; } - }) - }; - if let Some(screen) = changed_screen { - self.emit(MuxEvent::TreeChanged); + let before = screen.layout_snapshot(); + let changed = if screen.layout_columns_active() + && screen.root.contains(pane) + && screen.root.contains(target) + { + for column in &mut screen.layout_columns { + if column.root.contains(pane) || column.root.contains(target) { + column.zellij_auto_layout = None; + column.root.swap_leaf_ids(pane, target); + } + } + screen.sync_layout_column_projection(); + true + } else { + let changed = screen.root.swap_leaves(pane, target); + if changed { + screen.zellij_auto_layout = None; + } + changed + }; + if changed { + screen.record_layout_change(before, Vec::new(), None); + Some(screen.id) + } else { + None + } + }) + }; + if let Some(screen) = changed_screen { + self.emit(MuxEvent::TreeChanged); self.emit(MuxEvent::LayoutChanged(screen)); true } else { @@ -6379,6 +6961,7 @@ impl Mux { anyhow::bail!("unknown pane {target}"); }; let screen = &mut state.workspaces[wi].screens[si]; + let before = screen.layout_snapshot(); let next = match mode { ZoomMode::Toggle if screen.zoomed_pane == Some(target) => None, ZoomMode::Toggle => Some(target), @@ -6387,6 +6970,9 @@ impl Mux { }; let changed = screen.zoomed_pane != next; screen.zoomed_pane = next; + if changed { + screen.record_layout_change(before, Vec::new(), None); + } (screen.id, target, next, changed) }; if changed.3 { @@ -6396,6 +6982,286 @@ impl Mux { Ok(ZoomState { pane: changed.1, zoomed: changed.2.is_some(), zoomed_pane: changed.2 }) } + /// Undo the latest structural layout transaction on `pane`'s screen. + /// + /// Transactions that created panes return a confirmation preview first. + /// The caller must retry with the exact preview revision and + /// `confirm_close=true`, preventing a stale confirmation from closing a + /// pane created by a newer action. + pub fn undo_layout( + &self, + pane: PaneId, + expected_revision: Option, + confirm_close: bool, + ) -> anyhow::Result { + let (workspace, screen_id, preview) = { + let mut state = self.state.lock().unwrap(); + let Some((workspace_index, screen_index)) = state.screen_of(pane) else { + return Err(LayoutUndoError::Stale( + "layout undo target is no longer available".to_string(), + ) + .into()); + }; + let workspace = state.workspaces[workspace_index].id; + let screen_id = state.workspaces[workspace_index].screens[screen_index].id; + let entry = { + let screen = &state.workspaces[workspace_index].screens[screen_index]; + let Some(entry) = screen.layout_undo.back().cloned() else { + return Err(LayoutUndoError::Unavailable.into()); + }; + if entry.after_revision != screen.layout_revision { + return Err(LayoutUndoError::Stale( + "layout changed since the last undoable action".to_string(), + ) + .into()); + } + entry + }; + if let Some(expected) = expected_revision + && expected != entry.after_revision + { + return Err(LayoutUndoError::Stale(format!( + "layout revision conflict: expected {expected}, current {}", + entry.after_revision + )) + .into()); + } + if !entry.created_panes.is_empty() && !confirm_close { + let mut pane_tabs = Vec::with_capacity(entry.created_panes.len()); + for created in &entry.created_panes { + let Some(created_pane) = state.panes.get(created) else { + return Err(LayoutUndoError::Stale(format!( + "created pane {created} disappeared before undo preview" + )) + .into()); + }; + pane_tabs.push((*created, created_pane.tabs.clone())); + } + let screen = &mut state.workspaces[workspace_index].screens[screen_index]; + let revision = screen.layout_revision.saturating_add(1); + screen.layout_revision = revision; + let latest = screen + .layout_undo + .back_mut() + .expect("validated layout undo entry remains present"); + latest.after_revision = revision; + latest.confirmation = Some(LayoutUndoConfirmation { revision, pane_tabs }); + return Ok(LayoutUndoResult::ConfirmationRequired { + screen: screen_id, + revision, + closes_panes: entry.created_panes, + }); + } + (workspace, screen_id, entry) + }; + if !preview.created_panes.is_empty() && expected_revision.is_none() { + return Err(LayoutUndoError::Stale( + "confirmed layout undo requires the preview revision".to_string(), + ) + .into()); + } + if preview.created_panes.is_empty() { + let revision = { + let mut state = self.state.lock().unwrap(); + let Some((workspace_index, screen_index)) = state.screen_of(pane) else { + return Err(LayoutUndoError::Stale( + "layout undo target disappeared before the change could commit".to_string(), + ) + .into()); + }; + let screen = &mut state.workspaces[workspace_index].screens[screen_index]; + let Some(entry) = screen.layout_undo.pop_back() else { + return Err( + LayoutUndoError::Stale("layout undo disappeared".to_string()).into() + ); + }; + if entry.after_revision != screen.layout_revision + || expected_revision.is_some_and(|expected| expected != entry.after_revision) + { + screen.layout_undo.push_back(entry); + return Err(LayoutUndoError::Stale( + "layout changed before undo could commit".to_string(), + ) + .into()); + } + let revision = screen.layout_revision.saturating_add(1); + screen.restore_layout_snapshot(entry.before); + screen.layout_revision = revision; + if let Some(previous) = screen.layout_undo.back_mut() { + previous.after_revision = revision; + previous.coalesce = None; + previous.confirmation = None; + } + Self::rebuild_split_screen_index(&mut state); + revision + }; + self.emit(MuxEvent::TreeChanged); + self.emit(MuxEvent::LayoutChanged(screen_id)); + return Ok(LayoutUndoResult::Undone { screen: screen_id, revision }); + } + + let lifecycle = self.workspace_lifecycle(workspace); + let _workspace_lifecycle = lifecycle.lock().unwrap(); + let notifications = self.surface_notifications(); + let mut registry = self.workspace_registry.lock().unwrap(); + let mutation = WorkspaceMutation::local("cmux-tui-layout-undo"); + let (removed, deltas, selection_resync, terminal_revision, terminal_count, revision) = { + let mut state = self.state.lock().unwrap(); + let Some(workspace_index) = state.workspace_index(workspace) else { + return Err(LayoutUndoError::Stale( + "layout undo workspace is no longer available".to_string(), + ) + .into()); + }; + let Some(screen_index) = state.workspaces[workspace_index] + .screens + .iter() + .position(|screen| screen.id == screen_id) + else { + return Err(LayoutUndoError::Stale( + "layout undo screen is no longer available".to_string(), + ) + .into()); + }; + let entry = { + let screen = &state.workspaces[workspace_index].screens[screen_index]; + let Some(entry) = screen.layout_undo.back().cloned() else { + return Err( + LayoutUndoError::Stale("layout undo disappeared".to_string()).into() + ); + }; + if entry.after_revision != screen.layout_revision + || expected_revision != Some(entry.after_revision) + { + return Err(LayoutUndoError::Stale( + "layout changed before confirmed undo could commit".to_string(), + ) + .into()); + } + entry + }; + let mut remaining_history = + state.workspaces[workspace_index].screens[screen_index].layout_undo.clone(); + remaining_history.pop_back(); + + let mut before_panes = Vec::new(); + entry.before.root.pane_ids(&mut before_panes); + let before_panes = before_panes.into_iter().collect::>(); + let mut current_panes = Vec::new(); + state.workspaces[workspace_index].screens[screen_index] + .root + .pane_ids(&mut current_panes); + let expected_panes = before_panes + .iter() + .copied() + .chain(entry.created_panes.iter().copied()) + .collect::>(); + if current_panes.into_iter().collect::>() != expected_panes { + return Err(LayoutUndoError::Stale( + "screen panes changed since the action being undone".to_string(), + ) + .into()); + } + + let selection_before = active_tree_selection(&state); + let mut tabs = Vec::new(); + let mut deltas = Vec::new(); + let Some(confirmation) = entry + .confirmation + .as_ref() + .filter(|confirmation| confirmation.revision == entry.after_revision) + else { + return Err( + LayoutUndoError::Stale("layout undo confirmation expired".to_string()).into() + ); + }; + if confirmation + .pane_tabs + .iter() + .map(|(pane, _)| *pane) + .ne(entry.created_panes.iter().copied()) + { + return Err(LayoutUndoError::Stale( + "layout undo confirmation no longer matches its created panes".to_string(), + ) + .into()); + } + for (created, confirmed_tabs) in &confirmation.pane_tabs { + let Some(pane) = state.panes.get(created) else { + return Err(LayoutUndoError::Stale(format!( + "created pane {created} disappeared before undo" + )) + .into()); + }; + if &pane.tabs != confirmed_tabs { + return Err(LayoutUndoError::Stale(format!( + "tabs in pane {created} changed since the undo confirmation" + )) + .into()); + } + tabs.extend(confirmed_tabs.iter().copied()); + if let Some(delta) = close_pane_delta(&state, ¬ifications, *created) { + deltas.push(delta); + } + } + let hosted = tabs + .iter() + .filter_map(|id| state.surfaces.get(id)) + .filter_map(|surface| surface.terminal_host_identity()) + .map(|identity| (identity.terminal_id, Some(identity.incarnation))) + .collect::>(); + let batch = registry.close_terminals_atomically(&mutation, &hosted)?; + let mut removed = Vec::new(); + for surface in tabs { + if let (Some(surface), _) = remove_surface(self, &mut state, surface) { + removed.push(surface); + } + } + let Some(screen_index) = state.workspaces[workspace_index] + .screens + .iter() + .position(|screen| screen.id == screen_id) + else { + return Err(LayoutUndoError::Stale( + "layout changed while undo was closing panes".to_string(), + ) + .into()); + }; + let screen = &mut state.workspaces[workspace_index].screens[screen_index]; + let revision = screen.layout_revision.max(entry.after_revision).saturating_add(1); + screen.restore_layout_snapshot(entry.before); + screen.layout_revision = revision; + screen.layout_undo = remaining_history; + if let Some(previous) = screen.layout_undo.back_mut() { + previous.after_revision = revision; + previous.coalesce = None; + previous.confirmation = None; + } + Self::rebuild_split_screen_index(&mut state); + let selection_resync = selection_before != active_tree_selection(&state); + (removed, deltas, selection_resync, batch.revision, batch.closed, revision) + }; + drop(registry); + + for surface in removed { + self.purge_surface_side_tables(surface.id); + surface.kill(); + } + if terminal_count != 0 { + let registry = self.workspace_registry.lock().unwrap(); + self.emit_terminal_registry_changed(®istry, terminal_revision); + } + if deltas.is_empty() { + self.emit(MuxEvent::TreeChanged); + } else { + for delta in deltas { + self.emit_tree_delta(delta, selection_resync); + } + } + self.emit(MuxEvent::LayoutChanged(screen_id)); + Ok(LayoutUndoResult::Undone { screen: screen_id, revision }) + } + pub fn apply_layout( self: &Arc, workspace: Option, @@ -6472,6 +7338,11 @@ impl Mux { active_pane, zoomed_pane: None, zellij_auto_layout: None, + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }; let ws = &mut state.workspaces[workspace_index]; ws.screens.push(screen); @@ -6816,6 +7687,11 @@ impl Mux { active_pane: pane, zoomed_pane: None, zellij_auto_layout: Some(vec![pane]), + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }); state.workspaces[destination].active_screen = 0; pane @@ -7531,6 +8407,89 @@ fn clamp_split_ratio(ratio: f32) -> f32 { ratio.clamp(0.05, 0.95) } +fn append_to_auto_layout( + root: &mut Node, + auto_layout: &mut Option>, + pane: PaneId, + mut next_id: impl FnMut() -> SplitId, +) { + let mut panes = auto_layout.clone().unwrap_or_else(|| { + let mut panes = Vec::new(); + root.pane_ids(&mut panes); + panes.sort_unstable(); + panes + }); + let current_panes = root.pane_ids_vec().into_iter().collect::>(); + panes.retain(|pane| current_panes.contains(pane)); + panes.push(pane); + *root = crate::layout::zellij_default_pane_layout_with_ids(&panes, &mut next_id) + .expect("new pane layout always has at least one pane"); + *auto_layout = Some(panes); +} + +fn remove_pane_from_screen_layout(mux: &Mux, screen: &mut Screen, pane: PaneId) -> bool { + screen.invalidate_layout_undo(); + if screen.layout_columns_active() { + let Some(index) = + screen.layout_columns.iter().position(|column| column.root.contains(pane)) + else { + return true; + }; + let column = &mut screen.layout_columns[index]; + let root = std::mem::replace(&mut column.root, Node::Leaf(0)); + let stack_expanded = root.stack_expanded_pane(); + match root.remove_leaf(pane) { + Some(mut root) => { + if let Some(panes) = column.zellij_auto_layout.as_mut() { + panes.retain(|candidate| *candidate != pane); + if let Some(layout) = + crate::layout::zellij_default_pane_layout_with_ids(panes, &mut || { + mux.next_id() + }) + { + root = layout; + if let Some(expanded) = stack_expanded { + root.expand_stack_pane(expanded); + } + } else { + column.zellij_auto_layout = None; + } + } + column.root = root; + } + None => { + screen.layout_columns.remove(index); + } + } + if screen.layout_columns.is_empty() { + return false; + } + screen.collapse_single_layout_column(); + return true; + } + + let root = std::mem::replace(&mut screen.root, Node::Leaf(0)); + let stack_expanded = root.stack_expanded_pane(); + let Some(mut root) = root.remove_leaf(pane) else { + return false; + }; + if let Some(panes) = screen.zellij_auto_layout.as_mut() { + panes.retain(|candidate| *candidate != pane); + if let Some(layout) = + crate::layout::zellij_default_pane_layout_with_ids(panes, &mut || mux.next_id()) + { + root = layout; + if let Some(expanded) = stack_expanded { + root.expand_stack_pane(expanded); + } + } else { + screen.zellij_auto_layout = None; + } + } + screen.root = root; + true +} + fn unique_screen_ids(ids: impl IntoIterator) -> Vec { let mut unique = Vec::new(); for id in ids { @@ -7756,57 +8715,37 @@ fn remove_surface(mux: &Mux, state: &mut State, target: SurfaceId) -> (Option { - if let Some(panes) = zellij_auto_layout.as_mut() { - panes.retain(|pane| *pane != pane_id); - if let Some(layout) = - crate::layout::zellij_default_pane_layout_with_ids(panes, &mut || mux.next_id()) - { - root = layout; - if let Some(expanded) = stack_expanded { - root.expand_stack_pane(expanded); - } - } else { - zellij_auto_layout = None; - } - } - let next_active = if was_active { - let mut ids = Vec::new(); - root.pane_ids(&mut ids); - most_recent_pane(state, &ids) - } else { - None - }; - let screen = &mut state.workspaces[wi].screens[si]; - screen.root = root; - screen.zellij_auto_layout = zellij_auto_layout; - if let Some(next) = next_active { - screen.active_pane = next; - } - stamp_changed_active_pane(mux, state, previous_active); - return (removed, true); - } - None => { - // Screen emptied: drop it from the workspace. - let ws = &mut state.workspaces[wi]; - ws.screens.remove(si); - ws.active_screen = ws.active_screen.min(ws.screens.len().saturating_sub(1)); - if !ws.screens.is_empty() { - stamp_changed_active_pane(mux, state, previous_active); - return (removed, true); - } + if screen_remains { + let next_active = if was_active { + let mut ids = Vec::new(); + state.workspaces[wi].screens[si].root.pane_ids(&mut ids); + most_recent_pane(state, &ids) + } else { + None + }; + if let Some(next) = next_active { + state.workspaces[wi].screens[si].active_pane = next; } + stamp_changed_active_pane(mux, state, previous_active); + return (removed, true); + } + + // Screen emptied: drop it from the workspace. + let ws = &mut state.workspaces[wi]; + ws.screens.remove(si); + ws.active_screen = ws.active_screen.min(ws.screens.len().saturating_sub(1)); + if !ws.screens.is_empty() { + stamp_changed_active_pane(mux, state, previous_active); + return (removed, true); } // The screen emptied, but the workspace remains as a canonical registry @@ -7821,50 +8760,30 @@ fn collapse_empty_pane(mux: &Mux, state: &mut State, pane_id: PaneId) { let Some((wi, si)) = state.screen_of(pane_id) else { return; }; - let (was_active, root, mut zellij_auto_layout) = { + let (was_active, screen_remains) = { let screen = &mut state.workspaces[wi].screens[si]; let was_active = screen.active_pane == pane_id; if screen.zoomed_pane == Some(pane_id) { screen.zoomed_pane = None; } - let root = std::mem::replace(&mut screen.root, Node::Leaf(0)); - (was_active, root, screen.zellij_auto_layout.take()) + let screen_remains = remove_pane_from_screen_layout(mux, screen, pane_id); + (was_active, screen_remains) }; - let stack_expanded = root.stack_expanded_pane(); - match root.remove_leaf(pane_id) { - Some(mut root) => { - if let Some(panes) = zellij_auto_layout.as_mut() { - panes.retain(|pane| *pane != pane_id); - if let Some(layout) = - crate::layout::zellij_default_pane_layout_with_ids(panes, &mut || mux.next_id()) - { - root = layout; - if let Some(expanded) = stack_expanded { - root.expand_stack_pane(expanded); - } - } else { - zellij_auto_layout = None; - } - } - let next_active = if was_active { - let mut ids = Vec::new(); - root.pane_ids(&mut ids); - most_recent_pane(state, &ids) - } else { - None - }; - let screen = &mut state.workspaces[wi].screens[si]; - screen.root = root; - screen.zellij_auto_layout = zellij_auto_layout; - if let Some(next) = next_active { - screen.active_pane = next; - } - } - None => { - let ws = &mut state.workspaces[wi]; - ws.screens.remove(si); - ws.active_screen = ws.active_screen.min(ws.screens.len().saturating_sub(1)); + if screen_remains { + let next_active = if was_active { + let mut ids = Vec::new(); + state.workspaces[wi].screens[si].root.pane_ids(&mut ids); + most_recent_pane(state, &ids) + } else { + None + }; + if let Some(next) = next_active { + state.workspaces[wi].screens[si].active_pane = next; } + } else { + let ws = &mut state.workspaces[wi]; + ws.screens.remove(si); + ws.active_screen = ws.active_screen.min(ws.screens.len().saturating_sub(1)); } } @@ -7942,6 +8861,8 @@ mod tests { use super::*; use std::collections::HashMap; + use crate::layout::{DEFAULT_VIEWPORT_PANE_WIDTH, VirtualRect}; + fn test_mux() -> Arc { Mux::new_for_test("test", SurfaceOptions::default()) } @@ -8802,6 +9723,11 @@ mod tests { active_pane: p3, zoomed_pane: None, zellij_auto_layout: None, + viewport_splits: Default::default(), + viewport_base_width: None, + layout_columns: Vec::new(), + layout_revision: 0, + layout_undo: Default::default(), }], active_screen: 0, }], @@ -9258,79 +10184,1023 @@ mod tests { }); } - #[cfg(unix)] #[test] - fn close_pane_reports_registry_failure_without_mutating_topology() { - const TERMINAL: &str = "00000000000040008000000000000010"; - const INCARNATION: &str = "10000000000040008000000000000010"; + fn new_pane_right_wraps_the_screen_in_a_viewport_split() { let mux = test_mux(); - let workspace = mux - .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001002".into()), None) - .unwrap(); - let surface = - insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); - let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); - let events = mux.subscribe(); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); + let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); - let error = mux.close_pane(pane).unwrap_err(); - assert!(error.to_string().contains("close pane")); - assert!(format!("{error:#}").contains("forced terminal close failure")); - assert_eq!(mux.with_state(|state| state.pane_of(surface.id)), Some(pane)); - assert!(mux.surface(surface.id).is_some()); - assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() - .lifecycle, - TerminalLifecycle::Running - ); - assert!(events.recv_timeout(Duration::from_millis(25)).is_err()); + let appended = + mux.new_pane_right(second_pane, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); + let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); - assert!(mux.close_pane(pane).unwrap()); - assert!(mux.surface(surface.id).is_none()); - assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() - .lifecycle, - TerminalLifecycle::Tombstoned - ); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, appended_pane); + assert_eq!(screen.zoomed_pane, None); + assert_eq!(screen.viewport_splits.len(), 1); + let Node::Split { id, dir, ratio, a, b } = &screen.root else { + panic!("viewport pane must wrap the screen root"); + }; + assert_eq!(*dir, SplitDir::Right); + assert!((*ratio - 0.6).abs() < f32::EPSILON); + assert!(a.contains(first_pane)); + assert!(a.contains(second_pane)); + assert!(matches!(b.as_ref(), Node::Leaf(pane) if *pane == appended_pane)); + assert_eq!(screen.viewport_splits[id], DEFAULT_VIEWPORT_PANE_WIDTH); + + let layout = layout_screen_with_viewport( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 24 }, + Some(screen.active_pane), + screen.viewport_base_width.unwrap_or(1.0), + &screen.viewport_splits, + ); + assert_eq!(layout.virtual_width, 133); + assert_eq!(layout.rect_of(first_pane).unwrap().width, 40); + assert_eq!(layout.rect_of(second_pane).unwrap().width, 40); + assert_eq!( + layout.rect_of(appended_pane).unwrap(), + VirtualRect { x: 80, y: 0, width: 53, height: 24 } + ); + }); + assert_eq!(mux.pane_neighbor(second_pane, Direction::Right).unwrap(), Some(appended_pane)); + assert_eq!(mux.pane_neighbor(appended_pane, Direction::Left).unwrap(), Some(second_pane)); } - #[cfg(unix)] #[test] - fn close_screen_reports_registry_failure_without_mutating_topology() { - const TERMINAL: &str = "00000000000040008000000000000011"; - const INCARNATION: &str = "10000000000040008000000000000011"; + fn viewport_neighbor_navigation_remains_adjacent_past_u16_layout_extent() { let mux = test_mux(); - let workspace = mux - .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001003".into()), None) - .unwrap(); - let surface = - insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); - let screen = mux.with_state(|state| surface_screen_id(state, surface.id).unwrap()); - mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let mut panes = vec![first_pane]; - let error = mux.close_screen(screen).unwrap_err(); - assert!(error.to_string().contains("close screen")); - assert!(format!("{error:#}").contains("forced terminal close failure")); - assert_eq!(mux.with_state(|state| surface_screen_id(state, surface.id)), Some(screen)); - assert!(mux.surface(surface.id).is_some()); - assert_eq!( - mux.workspace_registry - .lock() - .unwrap() - .terminal_record(TERMINAL) - .unwrap() - .unwrap() + for _ in 0..12 { + let previous = *panes.last().unwrap(); + let surface = + mux.new_pane_right(previous, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); + panes.push(mux.with_state(|state| state.pane_of(surface.id).unwrap())); + } + + for pair in panes.windows(2) { + let [left, right] = pair else { unreachable!() }; + assert_eq!(mux.pane_neighbor(*left, Direction::Right).unwrap(), Some(*right)); + assert_eq!(mux.pane_neighbor(*right, Direction::Left).unwrap(), Some(*left)); + assert_eq!(mux.pane_focus_neighbor(*left, Direction::Right).unwrap(), Some(*right)); + assert_eq!(mux.pane_focus_neighbor(*right, Direction::Left).unwrap(), Some(*left)); + } + } + + #[test] + fn new_pane_right_rejects_invalid_width_before_spawning() { + let mux = test_mux(); + let first = mux.new_workspace(None, None).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let surfaces = mux.with_state(|state| state.surfaces.len()); + + assert!(mux.new_pane_right(pane, 0.0, None).is_err()); + assert_eq!(mux.with_state(|state| state.surfaces.len()), surfaces); + } + + #[test] + fn new_pane_right_discards_spawn_when_target_disappears_before_attachment() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let weak = Arc::downgrade(&mux); + *mux.viewport_split_after_spawn.lock().unwrap() = Some(Arc::new(move || { + weak.upgrade().unwrap().close_pane(first_pane).unwrap(); + })); + + let error = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap_err(); + + assert_eq!(error.to_string(), "pane creation failed"); + mux.with_state(|state| { + assert!(state.surfaces.is_empty()); + assert!(state.panes.is_empty()); + assert!(state.workspaces[0].screens.is_empty()); + }); + } + + #[test] + fn viewport_columns_resize_independently() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); + let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); + let appended = + mux.new_pane_right(second_pane, DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))).unwrap(); + let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); + + assert!(mux.set_viewport_pane_width(first_pane, 0.75)); + assert!(mux.set_viewport_pane_width(appended_pane, 0.5)); + assert!(!mux.set_viewport_pane_width(appended_pane, 0.0)); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, Some(0.75)); + let appended_split = match screen + .root + .viewport_column_owner(appended_pane, &screen.viewport_splits) + .unwrap() + { + ViewportColumn::Split(split) => split, + ViewportColumn::Base => panic!("appended pane must own a viewport split"), + }; + assert_eq!(screen.viewport_splits[&appended_split], 0.5); + let Node::Split { ratio, .. } = &screen.root else { + panic!("viewport layout must retain its root split"); + }; + assert!((*ratio - 0.6).abs() < f32::EPSILON); + + let layout = layout_screen_with_viewport( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 24 }, + Some(screen.active_pane), + screen.viewport_base_width.unwrap(), + &screen.viewport_splits, + ); + assert_eq!(layout.virtual_width, 100); + assert_eq!(layout.rect_of(first_pane).unwrap().width, 30); + assert_eq!(layout.rect_of(second_pane).unwrap().width, 30); + assert_eq!( + layout.rect_of(appended_pane).unwrap(), + VirtualRect { x: 60, y: 0, width: 40, height: 24 } + ); + }); + } + + #[test] + fn projected_viewport_split_ratio_resizes_the_authoritative_column() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let appended = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let appended_pane = mux.with_state(|state| state.pane_of(appended.id).unwrap()); + let split = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + match &screen.root { + Node::Split { id, .. } => *id, + _ => panic!("viewport layout must expose a compatibility split"), + } + }); + + let events = mux.subscribe(); + assert!(mux.set_split_ratio_checked(split, 0.5).is_ok()); + assert!(matches!(events.recv().unwrap(), MuxEvent::LayoutChanged(_))); + let after_change = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + (screen.layout_revision, screen.layout_undo.len()) + }); + assert!(mux.set_split_ratio_checked(split, 0.5).is_ok()); + assert!(matches!( + mux.set_split_ratio_checked(split, 0.25), + Err(LayoutRatioError::UnrepresentableViewportWidth { split: rejected, .. }) + if rejected == split + )); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!((screen.layout_revision, screen.layout_undo.len()), after_change); + assert_eq!(screen.viewport_splits[&split], 1.0); + assert_eq!( + screen + .layout_columns + .iter() + .find(|column| column.root.contains(appended_pane)) + .map(|column| column.width), + Some(1.0) + ); + assert!(matches!( + &screen.root, + Node::Split { id, ratio, .. } + if *id == split && (*ratio - 0.5).abs() < f32::EPSILON + )); + assert!(state.split_screens.contains_key(&split)); + }); + assert!(events.try_iter().next().is_none()); + } + + fn seed_high_ratio_viewport_projection() -> (Arc, PaneId, SplitId) { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let middle = mux.new_pane_right(first_pane, 1.0, Some((78, 22))).unwrap(); + let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); + let right = + mux.new_pane_right(middle_pane, MIN_VIEWPORT_PANE_WIDTH, Some((6, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let split = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let Node::Split { id, ratio, .. } = &screen.root else { + panic!("three columns should expose a projected root split"); + }; + assert!((*ratio - (2.0 / 2.1)).abs() < f32::EPSILON); + *id + }); + (mux, right_pane, split) + } + + fn assert_high_ratio_projection_resize_applied( + mux: &Mux, + expected_width: f32, + undo_count_before: usize, + ) { + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert!( + (screen.layout_columns[2].width - expected_width).abs() < 1e-6, + "projected ratio should update authoritative width: {:?}", + screen.layout_columns + ); + assert_eq!(screen.layout_undo.len(), undo_count_before + 1); + assert!(screen.layout_column_projection_is_consistent()); + }); + } + + #[test] + fn maximum_projected_split_ratio_still_resizes_authoritative_column() { + let (mux, _right_pane, split) = seed_high_ratio_viewport_projection(); + let expected_width = 2.0 * (1.0 - 0.95) / 0.95; + let undo_count_before = + mux.with_state(|state| state.workspaces[0].screens[0].layout_undo.len()); + let events = mux.subscribe(); + + assert!(mux.set_split_ratio_checked(split, 0.95).is_ok()); + + assert_high_ratio_projection_resize_applied(&mux, expected_width, undo_count_before); + assert!(matches!(events.try_recv(), Ok(MuxEvent::LayoutChanged(_)))); + } + + #[test] + fn maximum_pane_addressed_ratio_still_resizes_authoritative_column() { + let (mux, right_pane, _split) = seed_high_ratio_viewport_projection(); + let expected_width = 2.0 * (1.0 - 0.95) / 0.95; + let undo_count_before = + mux.with_state(|state| state.workspaces[0].screens[0].layout_undo.len()); + let events = mux.subscribe(); + + assert!(mux.set_ratio_checked(right_pane, SplitDir::Right, 0.95).is_ok()); + + assert_high_ratio_projection_resize_applied(&mux, expected_width, undo_count_before); + assert!(matches!(events.try_recv(), Ok(MuxEvent::TreeChanged))); + assert!(matches!(events.try_recv(), Ok(MuxEvent::LayoutChanged(_)))); + } + + #[test] + fn set_ratio_resizes_a_projected_viewport_split() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + + assert!(mux.set_ratio_checked(first_pane, SplitDir::Right, 0.5).is_ok()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let Node::Split { id, ratio, .. } = &screen.root else { + panic!("viewport layout must expose a compatibility split"); + }; + assert!((*ratio - 0.5).abs() < f32::EPSILON); + assert_eq!(screen.viewport_splits[id], 1.0); + assert!(screen.layout_column_projection_is_consistent()); + }); + } + + #[test] + fn new_pane_right_inserts_after_the_target_viewport_column() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let second = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let second_pane = mux.with_state(|state| state.pane_of(second.id).unwrap()); + let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let layout = layout_screen_with_viewport( + &screen.root, + Rect { x: 0, y: 0, width: 80, height: 24 }, + Some(screen.active_pane), + screen.viewport_base_width.unwrap(), + &screen.viewport_splits, + ); + assert_eq!( + layout.panes.iter().map(|(pane, _)| *pane).collect::>(), + vec![first_pane, middle_pane, second_pane] + ); + assert_eq!(layout.rect_of(first_pane).unwrap().x, 0); + assert_eq!(layout.rect_of(middle_pane).unwrap().x, 80); + assert_eq!(layout.rect_of(second_pane).unwrap().x, 120); + }); + } + + #[test] + fn viewport_self_swap_is_a_noop_without_undo_history_or_events() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let before = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + (screen.layout_revision, screen.layout_undo.len(), format!("{:?}", screen.root)) + }); + let events = mux.subscribe(); + + assert!(!mux.swap_panes(right_pane, right_pane)); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_revision, before.0); + assert_eq!(screen.layout_undo.len(), before.1); + assert_eq!(format!("{:?}", screen.root), before.2); + }); + assert!(events.try_iter().next().is_none()); + } + + #[test] + fn zellij_new_pane_rebalances_only_the_focused_layout_column() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + let right_added = mux.new_pane(right_pane, Some((38, 10))).unwrap(); + let right_added_pane = mux.with_state(|state| state.pane_of(right_added.id).unwrap()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns.len(), 2); + assert_eq!(screen.layout_columns[0].root.pane_ids_vec(), vec![first_pane]); + assert_eq!( + screen.layout_columns[1].root.pane_ids_vec(), + vec![right_pane, right_added_pane] + ); + assert_eq!( + screen.layout_columns[1].zellij_auto_layout.as_deref(), + Some([right_pane, right_added_pane].as_slice()) + ); + assert_eq!(screen.viewport_splits.len(), 1); + }); + + let left_added = mux.new_pane(first_pane, Some((38, 10))).unwrap(); + let left_added_pane = mux.with_state(|state| state.pane_of(left_added.id).unwrap()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!( + screen.layout_columns[0].root.pane_ids_vec(), + vec![first_pane, left_added_pane] + ); + assert_eq!( + screen.layout_columns[1].root.pane_ids_vec(), + vec![right_pane, right_added_pane] + ); + assert_eq!(screen.viewport_base_width, Some(1.0)); + assert_eq!(screen.viewport_splits.values().copied().collect::>(), vec![0.5]); + assert!(screen.layout_column_projection_is_consistent()); + }); + } + + #[test] + fn layout_undo_removes_only_the_pane_created_in_the_focused_column() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let added = mux.new_pane(right_pane, Some((38, 10))).unwrap(); + let added_pane = mux.with_state(|state| state.pane_of(added.id).unwrap()); + + let LayoutUndoResult::ConfirmationRequired { revision, closes_panes, .. } = + mux.undo_layout(added_pane, None, false).unwrap() + else { + panic!("new pane undo must require confirmation"); + }; + assert_eq!(closes_panes, vec![added_pane]); + assert!(matches!( + mux.undo_layout(added_pane, Some(revision), true).unwrap(), + LayoutUndoResult::Undone { .. } + )); + + assert!(mux.surface(added.id).is_none()); + assert!(mux.surface(right.id).is_some()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns.len(), 2); + assert_eq!(screen.layout_columns[0].root.pane_ids_vec(), vec![first_pane]); + assert_eq!(screen.layout_columns[1].root.pane_ids_vec(), vec![right_pane]); + assert!(screen.layout_column_projection_is_consistent()); + }); + } + + #[test] + fn layout_undo_confirmation_fences_exact_created_pane_tab_membership() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + let LayoutUndoResult::ConfirmationRequired { revision, .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("pane creation undo must require confirmation"); + }; + let late_tab = mux.new_tab(Some(right_pane), None, Some((38, 22))).unwrap(); + + let error = mux.undo_layout(right_pane, Some(revision), true).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(LayoutUndoError::Stale(message)) + if message.contains("tabs in pane") && message.contains("changed") + )); + assert!(mux.surface(right.id).is_some()); + assert!(mux.surface(late_tab.id).is_some()); + + let LayoutUndoResult::ConfirmationRequired { revision: refreshed, .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("a fresh preview must capture the new tab membership"); + }; + assert!(refreshed > revision); + assert!(matches!( + mux.undo_layout(right_pane, Some(refreshed), true).unwrap(), + LayoutUndoResult::Undone { .. } + )); + assert!(mux.surface(right.id).is_none()); + assert!(mux.surface(late_tab.id).is_none()); + } + + #[test] + fn layout_undo_public_edge_failures_are_typed() { + let mux = test_mux(); + let unknown_pane = mux.undo_layout(u64::MAX, None, false).unwrap_err(); + + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let LayoutUndoResult::ConfirmationRequired { .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("pane creation undo must require confirmation"); + }; + let missing_revision = mux.undo_layout(right_pane, None, true).unwrap_err(); + + assert_eq!( + [ + matches!( + unknown_pane.downcast_ref::(), + Some(LayoutUndoError::Stale(_)) + ), + matches!( + missing_revision.downcast_ref::(), + Some(LayoutUndoError::Stale(_)) + ), + ], + [true, true], + "public layout-undo edge failures must preserve their typed error" + ); + } + + #[test] + fn layout_undo_coalesces_resize_and_fences_pane_closure() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.6, 7, 11)); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert!( + screen + .layout_snapshot_for_coalescing_change(Some(LayoutMutationKey::Resize { + owner: LayoutResizeOwner::ControlClient(7), + transaction: 11, + })) + .is_none(), + "continuation samples must reuse the transaction's first snapshot" + ); + }); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 7, 11)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns[1].width, 0.5); + }); + + let LayoutUndoResult::ConfirmationRequired { revision, closes_panes, .. } = + mux.undo_layout(right_pane, None, false).unwrap() + else { + panic!("pane creation undo must require confirmation"); + }; + assert_eq!(closes_panes, vec![right_pane]); + assert!( + mux.undo_layout(right_pane, None, true) + .unwrap_err() + .to_string() + .contains("requires the preview revision") + ); + assert!( + mux.undo_layout(right_pane, Some(revision.saturating_sub(1)), true) + .unwrap_err() + .to_string() + .contains("revision conflict") + ); + assert!(mux.surface(right.id).is_some()); + + assert!(matches!( + mux.undo_layout(right_pane, Some(revision), true).unwrap(), + LayoutUndoResult::Undone { .. } + )); + assert!(mux.surface(right.id).is_none()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert!(!screen.layout_columns_active()); + assert!(screen.viewport_splits.is_empty()); + assert_eq!(screen.root.pane_ids_vec(), vec![first_pane]); + assert_eq!(screen.active_pane, first_pane); + assert!(screen.layout_column_projection_is_consistent()); + }); + } + + #[test] + fn layout_undo_preserves_focus_when_the_current_pane_survives() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + assert!(mux.focus_pane(first_pane)); + assert!(mux.set_viewport_pane_width(right_pane, 0.7)); + assert!(mux.focus_pane(right_pane)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, right_pane); + assert_eq!(screen.layout_columns[1].width, 0.5); + }); + } + + #[test] + fn layout_undo_restores_focus_to_the_restored_zoomed_pane() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.split(first_pane, SplitDir::Right, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + assert!(mux.focus_pane(first_pane)); + mux.zoom_pane(Some(first_pane), ZoomMode::On).unwrap(); + mux.zoom_pane(Some(first_pane), ZoomMode::Off).unwrap(); + assert!(mux.focus_pane(right_pane)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.zoomed_pane, Some(first_pane)); + assert_eq!(screen.active_pane, first_pane); + assert_eq!(state.active_pane(), Some(first_pane)); + }); + } + + #[test] + fn layout_undo_preserves_inactive_stack_selection() { + let mux = test_mux(); + let applied = mux + .apply_layout( + None, + None, + &split_spec( + SplitDir::Right, + 0.5, + LayoutSpec::Stack { pane_count: 2, expanded_index: 0 }, + LayoutSpec::Stack { pane_count: 2, expanded_index: 0 }, + ), + Some((80, 22)), + ) + .unwrap(); + let [left_first, left_second, right_first, _right_second] = + applied.panes.iter().map(|pane| pane.pane).collect::>()[..] + else { + panic!("two two-pane stacks should create four panes"); + }; + { + let mut state = mux.state.lock().unwrap(); + let screen = &mut state.workspaces[0].screens[0]; + let root = std::mem::replace(&mut screen.root, Node::Leaf(0)); + let Node::Split { id, a, b, .. } = root else { + panic!("test layout should have two stack branches"); + }; + screen.layout_columns = vec![ + LayoutColumn { id: mux.next_id(), width: 1.0, root: *a, zellij_auto_layout: None }, + LayoutColumn { id, width: 0.5, root: *b, zellij_auto_layout: None }, + ]; + screen.sync_layout_column_projection(); + Mux::rebuild_split_screen_index(&mut state); + } + + assert!(mux.focus_pane(right_first)); + assert!(mux.set_viewport_pane_width(right_first, 0.7)); + assert!(mux.focus_pane(left_second)); + assert!(mux.focus_pane(right_first)); + assert!(matches!( + mux.undo_layout(right_first, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.active_pane, right_first); + assert!(matches!( + &screen.layout_columns[0].root, + Node::Stack { expanded, .. } if *expanded == left_second + )); + assert!(!matches!( + &screen.layout_columns[0].root, + Node::Stack { expanded, .. } if *expanded == left_first + )); + assert!(matches!( + &screen.root, + Node::Split { a, .. } + if matches!( + a.as_ref(), + Node::Stack { expanded, .. } if *expanded == left_second + ) + )); + assert!(screen.layout_column_projection_is_consistent()); + }); + } + + #[test] + fn layout_undo_coalesces_every_target_in_one_resize_transaction() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let bottom = mux.split(right_pane, SplitDir::Down, Some((38, 10))).unwrap(); + let bottom_pane = mux.with_state(|state| state.pane_of(bottom.id).unwrap()); + let (split, initial_ratio, initial_undo_len) = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + let Node::Split { id, ratio, .. } = &screen.layout_columns[1].root else { + panic!("right viewport column must contain the vertical split"); + }; + (*id, *ratio, screen.layout_undo.len()) + }); + + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 9, 41)); + assert!(mux.set_split_ratio_in_transaction_checked(split, 0.7, 9, 41).is_ok()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_undo.len(), initial_undo_len + 1); + }); + + assert!(matches!( + mux.undo_layout(bottom_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns[1].width, 0.5); + let Node::Split { ratio, .. } = &screen.layout_columns[1].root else { + panic!("right viewport column must retain the vertical split"); + }; + assert!((*ratio - initial_ratio).abs() < f32::EPSILON); + assert_eq!(screen.layout_undo.len(), initial_undo_len); + }); + } + + #[test] + fn layout_undo_separates_resize_transactions_and_clients() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.6, 1, 1)); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 1, 1)); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.8, 1, 2)); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.65, 2, 2)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.8); + }); + + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.7); + }); + + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.5); + }); + } + + #[test] + fn layout_undo_separates_in_process_and_control_resize_owners_with_same_ids() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + // These model independent in-process and control-client entrypoints + // that happen to allocate the same numeric owner and transaction ids. + assert!( + mux.set_viewport_pane_width_in_process_transaction_checked(right_pane, 0.6, 1, 1) + .is_ok() + ); + assert!(mux.set_viewport_pane_width_in_transaction(right_pane, 0.7, 1, 1)); + + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.6); + }); + + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + assert_eq!(state.workspaces[0].screens[0].layout_columns[1].width, 0.5); + }); + } + + #[test] + fn in_process_resize_owner_allocation_is_mux_scoped() { + let first = test_mux(); + assert_eq!(first.allocate_in_process_resize_owner(), 1); + assert_eq!(first.allocate_in_process_resize_owner(), 2); + + let second = test_mux(); + assert_eq!(second.allocate_in_process_resize_owner(), 1); + } + + #[test] + fn layout_undo_separates_a_new_resize_from_pre_undo_history() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + assert!(mux.set_viewport_pane_width(right_pane, 0.6)); + assert!(mux.set_viewport_pane_width(first_pane, 0.9)); + assert!(matches!( + mux.undo_layout(first_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + + assert!(mux.set_viewport_pane_width(right_pane, 0.7)); + assert!(matches!( + mux.undo_layout(right_pane, None, false).unwrap(), + LayoutUndoResult::Undone { .. } + )); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.layout_columns[0].width, 1.0); + assert_eq!(screen.layout_columns[1].width, 0.6); + }); + } + + #[test] + fn closing_a_pane_invalidates_layout_undo_history() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + + assert!(mux.close_pane(right_pane).unwrap()); + let error = mux.undo_layout(first_pane, None, false).unwrap_err(); + + assert_eq!(error.to_string(), "no layout change to undo"); + assert!(mux.surface(right.id).is_none()); + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert!(!screen.layout_columns_active()); + assert!(screen.layout_column_projection_is_consistent()); + }); + } + + #[test] + fn closing_the_base_viewport_column_preserves_the_promoted_width() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); + let right = mux.new_pane_right(middle_pane, 0.4, Some((30, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + assert!(mux.set_viewport_pane_width(first_pane, 0.75)); + + assert!(mux.close_pane(first_pane).unwrap()); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, Some(0.5)); + assert_eq!( + screen.root.viewport_column_owner(middle_pane, &screen.viewport_splits), + Some(ViewportColumn::Base) + ); + let ViewportColumn::Split(right_split) = + screen.root.viewport_column_owner(right_pane, &screen.viewport_splits).unwrap() + else { + panic!("right pane must remain an appended column"); + }; + assert_eq!(screen.viewport_splits[&right_split], 0.4); + let Node::Split { ratio, .. } = &screen.root else { + panic!("two viewport columns must retain one split"); + }; + assert!((*ratio - (0.5 / 0.9)).abs() < 0.0001); + }); + } + + #[test] + fn closing_a_middle_viewport_column_recomputes_fallback_ratios() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let middle = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let middle_pane = mux.with_state(|state| state.pane_of(middle.id).unwrap()); + let right = mux.new_pane_right(middle_pane, 0.4, Some((30, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + assert!(mux.set_viewport_pane_width(first_pane, 0.75)); + + assert!(mux.close_pane(middle_pane).unwrap()); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, Some(0.75)); + let ViewportColumn::Split(right_split) = + screen.root.viewport_column_owner(right_pane, &screen.viewport_splits).unwrap() + else { + panic!("right pane must remain appended"); + }; + assert_eq!(screen.viewport_splits.len(), 1); + assert_eq!(screen.viewport_splits[&right_split], 0.4); + let Node::Split { ratio, .. } = &screen.root else { + panic!("two viewport columns must retain one split"); + }; + assert!((*ratio - (0.75 / 1.15)).abs() < 0.0001); + }); + } + + #[cfg(unix)] + #[test] + fn discard_spawned_restores_unbound_running_terminal_when_registry_close_fails() { + const TERMINAL: &str = "00000000000040008000000000000012"; + const INCARNATION: &str = "10000000000040008000000000000012"; + let mux = test_mux(); + let workspace = mux + .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001012".into()), None) + .unwrap(); + let surface = + insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); + { + let mut state = mux.state.lock().unwrap(); + let (removed, split_index_dirty) = remove_surface(&mux, &mut state, surface.id); + if split_index_dirty { + Mux::rebuild_split_screen_index(&mut state); + } + insert_surface_checked( + &mut state, + removed.expect("seeded terminal is removable from topology"), + ) + .unwrap(); + } + assert_eq!(mux.with_state(|state| state.pane_of(surface.id)), None); + let events = mux.subscribe(); + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); + + mux.discard_spawned(vec![surface.clone()]); + + let restored = mux + .with_state(|state| state.pane_of(surface.id)) + .expect("failed close must project the terminal back into reachable topology"); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Running + ); + assert!(events.try_iter().any(|event| { + matches!(event, MuxEvent::Status(message) + if message.contains("could not atomically close discarded terminals")) + })); + + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); + assert!(mux.close_pane(restored).unwrap()); + } + + #[cfg(unix)] + #[test] + fn close_pane_reports_registry_failure_without_mutating_topology() { + const TERMINAL: &str = "00000000000040008000000000000010"; + const INCARNATION: &str = "10000000000040008000000000000010"; + let mux = test_mux(); + let workspace = mux + .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001002".into()), None) + .unwrap(); + let surface = + insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); + let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + let events = mux.subscribe(); + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); + + let error = mux.close_pane(pane).unwrap_err(); + assert!(error.to_string().contains("close pane")); + assert!(format!("{error:#}").contains("forced terminal close failure")); + assert_eq!(mux.with_state(|state| state.pane_of(surface.id)), Some(pane)); + assert!(mux.surface(surface.id).is_some()); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Running + ); + assert!(events.recv_timeout(Duration::from_millis(25)).is_err()); + + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(false).unwrap(); + assert!(mux.close_pane(pane).unwrap()); + assert!(mux.surface(surface.id).is_none()); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() + .lifecycle, + TerminalLifecycle::Tombstoned + ); + } + + #[cfg(unix)] + #[test] + fn close_screen_reports_registry_failure_without_mutating_topology() { + const TERMINAL: &str = "00000000000040008000000000000011"; + const INCARNATION: &str = "10000000000040008000000000000011"; + let mux = test_mux(); + let workspace = mux + .create_empty_workspace(None, Some("018f6e21-7b70-7e70-8000-000000001003".into()), None) + .unwrap(); + let surface = + insert_running_terminal_identity_surface(&mux, TERMINAL, INCARNATION, &workspace.key); + let screen = mux.with_state(|state| surface_screen_id(state, surface.id).unwrap()); + mux.workspace_registry.lock().unwrap().set_terminal_close_failure(true).unwrap(); + + let error = mux.close_screen(screen).unwrap_err(); + assert!(error.to_string().contains("close screen")); + assert!(format!("{error:#}").contains("forced terminal close failure")); + assert_eq!(mux.with_state(|state| surface_screen_id(state, surface.id)), Some(screen)); + assert!(mux.surface(surface.id).is_some()); + assert_eq!( + mux.workspace_registry + .lock() + .unwrap() + .terminal_record(TERMINAL) + .unwrap() + .unwrap() .lifecycle, TerminalLifecycle::Running ); @@ -10006,7 +11876,7 @@ mod tests { let mux = test_mux(); let (p1, p2, p3) = seed_split_ratio_tree(&mux); - assert!(mux.set_ratio(p1, SplitDir::Right, 0.8)); + assert!(mux.set_ratio_checked(p1, SplitDir::Right, 0.8).is_ok()); mux.with_state(|s| { let root = &s.workspaces[0].screens[0].root; let Node::Split { ratio: root_ratio, a, .. } = root else { @@ -10019,7 +11889,7 @@ mod tests { assert_eq!(*inner_ratio, 0.8); }); - assert!(mux.set_ratio(p2, SplitDir::Right, -1.0)); + assert!(mux.set_ratio_checked(p2, SplitDir::Right, -1.0).is_ok()); mux.with_state(|s| { let Node::Split { ratio, .. } = &s.workspaces[0].screens[0].root else { panic!("root should be split"); @@ -10027,7 +11897,7 @@ mod tests { assert_eq!(*ratio, 0.05); }); - assert!(mux.set_ratio(p3, SplitDir::Right, 2.0)); + assert!(mux.set_ratio_checked(p3, SplitDir::Right, 2.0).is_ok()); mux.with_state(|s| { let Node::Split { a, .. } = &s.workspaces[0].screens[0].root else { panic!("root should be split"); @@ -10038,7 +11908,40 @@ mod tests { assert_eq!(*ratio, 0.95); }); - assert!(!mux.set_ratio(9999, SplitDir::Right, 0.4)); + assert!(matches!( + mux.set_ratio_checked(9999, SplitDir::Right, 0.4), + Err(LayoutRatioError::UnknownPaneSplit { pane: 9999 }) + )); + } + + #[test] + fn unchanged_ratio_commands_preserve_undo_metadata_revision_and_events() { + let mux = test_mux(); + let (p1, _, _) = seed_split_ratio_tree(&mux); + mux.state.lock().unwrap().workspaces[0].screens[0].zellij_auto_layout = Some(vec![1, 2, 3]); + let before = mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + (screen.layout_revision, screen.layout_undo.len(), screen.zellij_auto_layout.clone()) + }); + let events = mux.subscribe(); + + assert!(mux.set_split_ratio_checked(10, 0.5).is_ok()); + assert!(mux.set_ratio_checked(p1, SplitDir::Right, 0.5).is_ok()); + + mux.with_state(|state| { + let screen = &state.workspaces[0].screens[0]; + assert_eq!( + ( + screen.layout_revision, + screen.layout_undo.len(), + screen.zellij_auto_layout.clone(), + ), + before + ); + assert!(state.split_screens.contains_key(&10)); + assert!(state.split_screens.contains_key(&11)); + }); + assert!(events.try_iter().next().is_none()); } #[test] @@ -10048,7 +11951,7 @@ mod tests { mux.state.lock().unwrap().workspaces[0].screens[0].zellij_auto_layout = Some(vec![1, 2, 3]); let events = mux.subscribe(); - assert!(mux.set_split_ratio(10, 2.0)); + assert!(mux.set_split_ratio_checked(10, 2.0).is_ok()); mux.with_state(|s| { let Node::Split { id, ratio: root_ratio, a, .. } = &s.workspaces[0].screens[0].root else { @@ -10065,7 +11968,10 @@ mod tests { }); assert!(matches!(events.recv().unwrap(), MuxEvent::LayoutChanged(1))); assert!(events.try_recv().is_err()); - assert!(!mux.set_split_ratio(9999, 0.4)); + assert!(matches!( + mux.set_split_ratio_checked(9999, 0.4), + Err(LayoutRatioError::UnknownSplit { split: 9999 }) + )); } #[test] @@ -10099,7 +12005,7 @@ mod tests { assert_eq!(state.split_screens.get(&nested).map(|location| location.2), Some(screen)); }); assert!(mux.swap_panes(p1, p3)); - assert!(mux.set_split_ratio(original, 0.7)); + assert!(mux.set_split_ratio_checked(original, 0.7).is_ok()); mux.with_state(|s| { let Node::Split { id, ratio, .. } = &s.workspaces[0].screens[0].root else { diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 4c2553307eb1..19fd38ba7a4a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -51,14 +51,18 @@ use crate::surface::{ }; use crate::{ AgentRecord, AgentSource, AgentState, AttachFrame, DefaultColors, Direction, LayoutLeafSpec, - LayoutSpec, Mux, MuxEvent, Node, NotificationLevel, PairingDecision, PaneId, RenderAttachFrame, - Rgb, ScreenId, SidebarPluginStatus, SplitDir, SplitId, SurfaceId, SurfaceKind, - SurfaceNotification, SurfaceRenderFrame, TerminalColors, TreeDelta, TreeDeltaKind, WorkspaceId, - WorkspaceMutation, ZoomMode, assign_short_ids, + LayoutRatioError, LayoutSpec, LayoutUndoResult, Mux, MuxEvent, Node, NotificationLevel, + PairingDecision, PaneId, RenderAttachFrame, Rgb, ScreenId, SidebarPluginStatus, SplitDir, + SplitId, SurfaceId, SurfaceKind, SurfaceNotification, SurfaceRenderFrame, TerminalColors, + TreeDelta, TreeDeltaKind, ViewportWidthError, WorkspaceId, WorkspaceMutation, ZoomMode, + assign_short_ids, }; const ATTACH_INITIAL_SIZE_CAPABILITY: &str = "attach-initial-size"; const WORKSPACE_REGISTRY_CAPABILITY: &str = "workspace-registry-v1"; +pub const VIEWPORT_SPLITS_CAPABILITY: &str = "viewport-splits-v1"; +pub const VIEWPORT_COLUMN_RESIZE_CAPABILITY: &str = "viewport-column-resize-v1"; +pub const LAYOUT_UNDO_CAPABILITY: &str = "layout-undo-v1"; pub const CLEAR_HISTORY_CAPABILITY: &str = "clear-history-v1"; pub const CLEAR_HISTORY_KEY_CAPABILITY: &str = "clear-history-key-v1"; pub const SURFACE_SUBSCRIBE_FILTER_CAPABILITY: &str = "surface-subscribe-filter"; @@ -75,6 +79,9 @@ fn advertised_capabilities(bounded_clear_history_fallback_writes: bool) -> Vec<& let mut capabilities = vec![ ATTACH_INITIAL_SIZE_CAPABILITY, WORKSPACE_REGISTRY_CAPABILITY, + VIEWPORT_SPLITS_CAPABILITY, + VIEWPORT_COLUMN_RESIZE_CAPABILITY, + LAYOUT_UNDO_CAPABILITY, CLEAR_HISTORY_CAPABILITY, SURFACE_SUBSCRIBE_FILTER_CAPABILITY, PROVIDER_MANAGED_WORKSPACE_GUARD_CAPABILITY, @@ -784,6 +791,15 @@ enum Command { #[serde(default)] rows: Option, }, + NewPaneRight { + pane: PaneId, + #[serde(default)] + width: Option, + #[serde(default)] + cols: Option, + #[serde(default)] + rows: Option, + }, Split { pane: PaneId, /// "right" or "down" @@ -802,6 +818,21 @@ enum Command { SetSplitRatio { split: SplitId, ratio: f32, + #[serde(default)] + transaction: Option, + }, + SetViewportPaneWidth { + pane: PaneId, + width: f32, + #[serde(default)] + transaction: Option, + }, + UndoLayout { + pane: PaneId, + #[serde(default)] + revision: Option, + #[serde(default)] + confirm_close: bool, }, PaneNeighbor { pane: PaneId, @@ -1098,6 +1129,8 @@ struct Response { #[serde(skip_serializing_if = "Option::is_none")] error: Option, #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] error_delivery: Option, } @@ -2950,11 +2983,26 @@ fn handle_request_with_cancellation( let detach_self = matches!(&cmd, Command::DetachClient { client: target } if *target == client); let shutdown_daemon = matches!(&cmd, Command::ShutdownDaemon { .. }); let response = match handle_command_with_cancellation(mux, client, cmd, writer, cancellation) { - Ok(data) => Response { id, ok: true, data: Some(data), error: None, error_delivery: None }, + Ok(data) => Response { + id, + ok: true, + data: Some(data), + error: None, + error_code: None, + error_delivery: None, + }, Err(error) => { + let error_code = response_error_code(&error); let error_delivery = error.downcast_ref::().map(|error| error.delivery); - Response { id, ok: false, data: None, error: Some(error.to_string()), error_delivery } + Response { + id, + ok: false, + data: None, + error: Some(error.to_string()), + error_code, + error_delivery, + } } }; let response_ok = response.ok; @@ -2976,6 +3024,16 @@ fn handle_request_with_cancellation( sent } +fn response_error_code(error: &anyhow::Error) -> Option { + error + .downcast_ref::() + .map(|error| error.code().to_string()) + .or_else(|| error.downcast_ref::().map(|error| error.code().to_string())) + .or_else(|| { + error.downcast_ref::().map(|error| error.code().to_string()) + }) +} + fn send_request_error(writer: &MessageWriter, id: Option, error: &str) -> bool { send_request_error_with_delivery(writer, id, error, None) } @@ -2988,7 +3046,14 @@ fn send_request_error_with_delivery( ) -> bool { send_response( writer, - Response { id, ok: false, data: None, error: Some(error.to_string()), error_delivery }, + Response { + id, + ok: false, + data: None, + error: Some(error.to_string()), + error_code: None, + error_delivery, + }, ) } @@ -3167,7 +3232,7 @@ fn export_layout_json(state: &State, screen_id: Option) -> anyhow::Res }; let mut pane_ids = Vec::new(); screen.root.pane_ids(&mut pane_ids); - Ok(json!({ + let mut value = json!({ "layout": node_json(&screen.root, screen.active_pane), "panes": pane_ids.iter().map(|pane_id| { let surfaces = state @@ -3177,7 +3242,20 @@ fn export_layout_json(state: &State, screen_id: Option) -> anyhow::Res .unwrap_or_default(); json!({ "pane": pane_id, "surfaces": surfaces }) }).collect::>(), - })) + }); + if !screen.viewport_splits.is_empty() { + value["viewport_splits"] = json!( + screen + .viewport_splits + .iter() + .map(|(split, width)| json!({"split": split, "width": width})) + .collect::>() + ); + if let Some(width) = screen.viewport_base_width { + value["viewport_base_width"] = json!(width); + } + } + Ok(value) } fn pane_json( @@ -3240,7 +3318,7 @@ fn screen_json( ) -> Value { let mut pane_ids = Vec::new(); screen.root.pane_ids(&mut pane_ids); - json!({ + let mut value = json!({ "id": screen.id, "short_id": short_ids.get(&screen.id).cloned().unwrap_or_default(), "name": screen.name, @@ -3249,7 +3327,20 @@ fn screen_json( "zoomed_pane": screen.zoomed_pane, "layout": node_json(&screen.root, screen.active_pane), "panes": pane_ids.iter().map(|id| pane_json(state, *id, short_ids, notifications)).collect::>(), - }) + }); + if !screen.viewport_splits.is_empty() { + value["viewport_splits"] = json!( + screen + .viewport_splits + .iter() + .map(|(split, width)| json!({"split": split, "width": width})) + .collect::>() + ); + if let Some(width) = screen.viewport_base_width { + value["viewport_base_width"] = json!(width); + } + } + value } fn workspaces_json( @@ -3716,18 +3807,24 @@ fn browser_state_json( }); if include_frame { value["frame"] = match state.frame.as_ref() { - Some(frame) => json!({ - "seq": frame.seq, - "width": frame.css_width, - "height": frame.css_height, - "data": frame.data_b64, - }), + Some(frame) => browser_frame_json(frame), None => Value::Null, }; } value } +fn browser_frame_json(frame: &crate::BrowserFrame) -> Value { + json!({ + "seq": frame.seq, + "width": frame.css_width, + "height": frame.css_height, + "image_width": frame.image_width, + "image_height": frame.image_height, + "data": frame.data_b64, + }) +} + fn spawn_attach_notification_stream( mux: Arc, surface_id: SurfaceId, @@ -4754,6 +4851,14 @@ fn handle_command_with_cancellation( let surface = mux.new_pane(pane, optional_surface_size(cols, rows))?; Ok(json!({ "surface": surface.id })) } + Command::NewPaneRight { pane, width, cols, rows } => { + let surface = mux.new_pane_right( + pane, + width.unwrap_or(crate::DEFAULT_VIEWPORT_PANE_WIDTH), + optional_surface_size(cols, rows), + )?; + Ok(json!({ "surface": surface.id })) + } Command::Split { pane, dir, cols, rows } => { let dir = parse_split_dir(&dir)?; let surface = mux.split(pane, dir, optional_surface_size(cols, rows))?; @@ -4761,17 +4866,50 @@ fn handle_command_with_cancellation( } Command::SetRatio { pane, dir, ratio } => { let dir = parse_split_dir(&dir)?; - if !mux.set_ratio(pane, dir, ratio) { - anyhow::bail!("unknown pane/split {pane}"); - } + mux.set_ratio_checked(pane, dir, ratio)?; Ok(json!({})) } - Command::SetSplitRatio { split, ratio } => { - if !mux.set_split_ratio(split, ratio) { - anyhow::bail!("unknown split {split}"); - } + Command::SetSplitRatio { split, ratio, transaction } => { + transaction.map_or_else( + || mux.set_split_ratio_checked(split, ratio), + |transaction| { + mux.set_split_ratio_in_transaction_checked(split, ratio, client, transaction) + }, + )?; + Ok(json!({})) + } + Command::SetViewportPaneWidth { pane, width, transaction } => { + transaction.map_or_else( + || mux.set_viewport_pane_width_checked(pane, width), + |transaction| { + mux.set_viewport_pane_width_in_transaction_checked( + pane, + width, + client, + transaction, + ) + }, + )?; Ok(json!({})) } + Command::UndoLayout { pane, revision, confirm_close } => { + match mux.undo_layout(pane, revision, confirm_close)? { + LayoutUndoResult::Undone { screen, revision } => Ok(json!({ + "undone": true, + "screen": screen, + "revision": revision, + })), + LayoutUndoResult::ConfirmationRequired { screen, revision, closes_panes } => { + Ok(json!({ + "undone": false, + "confirmation_required": true, + "screen": screen, + "revision": revision, + "closes_panes": closes_panes, + })) + } + } + } Command::PaneNeighbor { pane, dir } => { let dir = parse_direction(&dir)?; let pane = mux.pane_neighbor(pane, dir)?; @@ -5413,14 +5551,9 @@ fn handle_command_with_cancellation( } } if let Some(frame) = update.frame { - let value = json!({ - "event": "frame", - "surface": surface_id, - "seq": frame.seq, - "width": frame.css_width, - "height": frame.css_height, - "data": frame.data_b64, - }); + let mut value = browser_frame_json(&frame); + value["event"] = json!("frame"); + value["surface"] = json!(surface_id); if let Err(error) = writer.send_stream(&value, &outbound_stream) { handle_attach_send_error(&lifecycle, &error); break; @@ -5804,6 +5937,33 @@ mod tests { }) } + #[test] + fn browser_state_serializes_css_and_encoded_image_dimensions() { + let state = crate::BrowserAttachState { + url: "https://example.com".to_string(), + title: "Example".to_string(), + cols: 80, + rows: 24, + status: crate::BrowserStatus::Live, + frame: Some(crate::BrowserFrame { + session_id: "browser-session".to_string(), + data_b64: "frame".to_string(), + css_width: 800, + css_height: 600, + image_width: 400, + image_height: 300, + seq: 7, + }), + frames_stalled: false, + }; + + let value = browser_state_json(3, &state, true); + assert_eq!(value["frame"]["width"], 800); + assert_eq!(value["frame"]["height"], 600); + assert_eq!(value["frame"]["image_width"], 400); + assert_eq!(value["frame"]["image_height"], 300); + } + #[test] fn stack_json_uses_the_stored_expansion_while_focus_is_elsewhere() { let stack = Node::stack_with_expanded(vec![1, 2, 3], 2).unwrap(); @@ -6719,6 +6879,89 @@ mod tests { ); } + #[test] + fn projected_split_ratio_range_failure_is_not_reported_as_unknown() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + mux.new_pane_right(pane, 0.5, Some((38, 22))).unwrap(); + let split = + handle_command(&mux, 0, Command::ListWorkspaces, &test_writer()).unwrap()["workspaces"] + [0]["screens"][0]["layout"]["split"] + .as_u64() + .expect("viewport projection exposes a stable split"); + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + + handle_message( + &mux, + 7, + &json!({ + "id": 21, + "cmd": "set-split-ratio", + "split": split, + "ratio": 0.25 + }) + .to_string(), + &writer, + ); + + let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(response["id"], 21); + assert_eq!(response["ok"], false); + assert_eq!(response["error_code"], LayoutRatioError::OUT_OF_RANGE_CODE); + assert!(response["error"].as_str().unwrap().contains("width must be between")); + assert!(!response["error"].as_str().unwrap().contains("unknown split")); + } + + #[test] + fn viewport_width_failures_have_stable_error_codes() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + + for (id, width, code) in [ + (31, 0.5, ViewportWidthError::COLUMN_MISSING_CODE), + (32, 1.1, ViewportWidthError::OUT_OF_RANGE_CODE), + ] { + handle_message( + &mux, + 7, + &json!({ + "id": id, + "cmd": "set-viewport-pane-width", + "pane": pane, + "width": width + }) + .to_string(), + &writer, + ); + let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(response["id"], id); + assert_eq!(response["ok"], false); + assert_eq!(response["error_code"], code); + } + + handle_message( + &mux, + 7, + &json!({ + "id": 33, + "cmd": "new-pane-right", + "pane": pane, + "width": 1.1 + }) + .to_string(), + &writer, + ); + let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(response["id"], 33); + assert_eq!(response["ok"], false); + assert_eq!(response["error_code"], ViewportWidthError::OUT_OF_RANGE_CODE); + } + #[test] fn create_terminal_rejects_partial_dimensions() { let mux = test_mux(); @@ -8418,6 +8661,9 @@ mod tests { for expected in [ "attach-initial-size", "workspace-registry-v1", + VIEWPORT_SPLITS_CAPABILITY, + VIEWPORT_COLUMN_RESIZE_CAPABILITY, + LAYOUT_UNDO_CAPABILITY, CLEAR_HISTORY_CAPABILITY, CLEAR_HISTORY_KEY_CAPABILITY, "surface-subscribe-filter", @@ -8427,6 +8673,69 @@ mod tests { } } + #[test] + fn layout_undo_protocol_requires_the_preview_revision_before_closing_a_pane() { + let mux = test_mux(); + let first = mux.new_workspace(None, Some((80, 22))).unwrap(); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(first_pane, 0.5, Some((38, 22))).unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let writer = test_writer(); + + let preview = handle_command( + &mux, + 0, + Command::UndoLayout { pane: right_pane, revision: None, confirm_close: false }, + &writer, + ) + .unwrap(); + let revision = preview["revision"].as_u64().expect("preview revision"); + assert_eq!(preview["undone"].as_bool(), Some(false)); + assert_eq!(preview["confirmation_required"].as_bool(), Some(true)); + assert_eq!(preview["closes_panes"], json!([right_pane])); + + let error = handle_command( + &mux, + 0, + Command::UndoLayout { pane: right_pane, revision: None, confirm_close: true }, + &writer, + ) + .unwrap_err(); + assert!(error.to_string().contains("requires the preview revision")); + assert!(mux.surface(right.id).is_some()); + + let result = handle_command( + &mux, + 0, + Command::UndoLayout { pane: right_pane, revision: Some(revision), confirm_close: true }, + &writer, + ) + .unwrap(); + assert_eq!(result["undone"].as_bool(), Some(true)); + assert!(mux.surface(right.id).is_none()); + } + + #[test] + fn layout_undo_protocol_serializes_the_machine_readable_error_code() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((80, 22))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + + handle_message( + &mux, + 7, + &json!({"id": 19, "cmd": "undo-layout", "pane": pane}).to_string(), + &writer, + ); + + let response: Value = serde_json::from_str(&outbound.try_pop().unwrap()).unwrap(); + assert_eq!(response["id"], 19); + assert_eq!(response["ok"], false); + assert_eq!(response["error_code"], crate::LayoutUndoError::UNAVAILABLE_CODE); + } + #[test] fn identify_advertises_clear_history_key_only_with_bounded_fallback_writes() { let unsupported = advertised_capabilities(false); diff --git a/cmux-tui/crates/cmux-tui-core/src/surface.rs b/cmux-tui/crates/cmux-tui-core/src/surface.rs index f701a2ac5a69..87f6d98f73d9 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface.rs @@ -532,6 +532,9 @@ pub struct SurfaceMeta { } /// A pane tab runtime. +// Surface values are always stored behind Arc, so boxing one variant would add +// a second allocation and pointer chase without shrinking their owning state. +#[allow(clippy::large_enum_variant)] pub enum Surface { Pty(PtySurface), Browser(BrowserSurface), @@ -2634,9 +2637,17 @@ impl Surface { } pub fn browser_frame(&self) -> Option { + self.browser_frame_shared().map(|frame| frame.as_ref().clone()) + } + + pub fn browser_frame_shared(&self) -> Option> { self.as_browser().and_then(BrowserSurface::latest_frame) } + pub fn has_browser_frame(&self) -> bool { + self.as_browser().is_some_and(BrowserSurface::has_latest_frame) + } + pub fn browser_url(&self) -> Option { self.as_browser().map(BrowserSurface::url) } diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index aa877e8dd0cf..b960fb7af03b 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -8,6 +8,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::io::Write; +use std::panic::AssertUnwindSafe; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError, sync_channel}; @@ -17,10 +18,13 @@ use std::time::{Duration, Instant}; use base64::Engine; use cmux_tui_core::{ - BrowserSource, BrowserStatus, ClearHistoryDelivery, ClearHistoryFailure, Direction, MuxEvent, - Node, PairingChallenge, PaneId, Rect, SplitDir, SplitEdge, SplitId, SurfaceId, SurfaceKind, - WorkspaceId, ZoomMode, exact_split_for_pane_edge, layout_screen, split_sides, - zellij_default_pane_layout, + BrowserFrame, BrowserSource, BrowserStatus, ClearHistoryDelivery, ClearHistoryFailure, + DEFAULT_VIEWPORT_PANE_WIDTH, Direction, LayoutUndoError, LayoutUndoResult, + MAX_VIEWPORT_PANE_WIDTH, MIN_VIEWPORT_PANE_WIDTH, MuxEvent, Node, PairingChallenge, PaneId, + Rect, ScreenId, SplitDir, SplitEdge, SplitId, SurfaceId, SurfaceKind, ViewportColumn, + ViewportLayoutResult, VirtualRect, WorkspaceId, ZoomMode, exact_split_for_pane_edge, + exact_split_for_pane_edge_with_viewport, layout_screen, layout_screen_with_viewport, + split_sides, zellij_default_pane_layout, }; use crossterm::ExecutableCommand; use crossterm::event::{ @@ -56,9 +60,11 @@ use crate::machine::{ WorkspaceCreationPolicy, validate_machine_session, }; use crate::pty_input::{ - PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, PtyInputEvent, PtyInputKind, - PtyInputSender, PtyOperationDelivery, PtyOperationFailure, mark_operation_known_not_delivered, + PTY_OPERATION_QUEUE_CAPACITY, PtyInputBytes, PtyInputDispatcher, PtyInputEnqueueResult, + PtyInputEvent, PtyInputKind, PtyInputSender, PtyOperationDelivery, PtyOperationFailure, + mark_operation_known_not_delivered, }; +use crate::session::tree::{PaneView, ScreenView}; use crate::session::{ CLEAR_HISTORY_UNSUPPORTED_ERROR, ClientInfo, Session, SidebarPluginSurface, SurfaceHandle, TreeView, is_remote_surface_unavailable, is_remote_timeout, is_remote_transport_failure, @@ -68,6 +74,7 @@ use crate::ui::graphics::GraphicPlacement; use crate::ui::graphics_writer::{GraphicsWriter, StdoutLock}; use crate::ui::input::{InputEvent, TextInput}; use crate::ui::{ + ReusableRowBuffer, horizontal_drag_offset, horizontal_offset_at, horizontal_thumb_geometry, thumb_geometry, viewport_drag_offset, viewport_jump_offset, viewport_thumb_geometry, }; @@ -148,6 +155,7 @@ pub enum AppEvent { MuxRecoveryComplete { recovery_generation: u64, }, + HostInputFailed(String), Input(Event), BrowserResizeFailed(BrowserResizeFailure), PtyFailuresReady, @@ -472,8 +480,14 @@ fn start_ordered_session_inner( let stop = Arc::new(AtomicBool::new(false)); let start = Arc::new(AtomicBool::new(!paused)); let events = SessionEventSender::scoped(app_events, generation, surface_filter, stop.clone()); + let layout_resize_owner = inner.allocate_layout_resize_owner(); let operations = operations.for_session_generation(generation); - let session = OrderedSession::new_with_event_sender(inner, operations, events.clone()); + let session = OrderedSession::new_with_event_sender( + inner, + operations, + events.clone(), + layout_resize_owner, + ); let mux_titles = Arc::new(MuxTitleIngress::default()); let mux_recovery_generation = Arc::new(AtomicU64::new(0)); let event_source = session.inner.clone(); @@ -691,6 +705,17 @@ pub struct SessionCompletion { enum SessionCompletionAction { SurfaceCreated { surface: SurfaceId }, BrowserTabCreated { surface: SurfaceId }, + LayoutUndoConfirmation { pane: PaneId, revision: u64, closes_panes: Vec }, + LayoutUndoUnavailable, + LayoutUndoStale, +} + +fn layout_undo_error_completion(error: &anyhow::Error) -> Option { + match error.downcast_ref::() { + Some(LayoutUndoError::Unavailable) => Some(SessionCompletionAction::LayoutUndoUnavailable), + Some(LayoutUndoError::Stale(_)) => Some(SessionCompletionAction::LayoutUndoStale), + None => None, + } } struct PendingSessionMutationState { @@ -970,20 +995,33 @@ pub struct OrderedSession { config_generation: Arc, sidebar_plugin_sync: Arc>, exited_surfaces: Arc>>, + layout_resize_owner: u64, + layout_resize_transaction: Arc, #[cfg(test)] surface_attach_after_obsolete_check: SurfaceAttachAfterObsoleteCheckHook, } impl OrderedSession { #[cfg(test)] - fn new(inner: Session, operations: PtyInputSender, events: SyncSender) -> Self { - Self::new_with_event_sender(inner, operations, SessionEventSender::unscoped(events)) + fn new( + inner: Session, + operations: PtyInputSender, + events: SyncSender, + layout_resize_owner: u64, + ) -> Self { + Self::new_with_event_sender( + inner, + operations, + SessionEventSender::unscoped(events), + layout_resize_owner, + ) } fn new_with_event_sender( inner: Session, operations: PtyInputSender, events: SessionEventSender, + layout_resize_owner: u64, ) -> Self { let remote = matches!(inner, Session::Remote(_)); Self { @@ -1012,6 +1050,8 @@ impl OrderedSession { config_generation: Arc::new(AtomicU64::new(0)), sidebar_plugin_sync: Arc::new(Mutex::new(SidebarPluginSyncState::default())), exited_surfaces: Arc::new(Mutex::new(HashSet::new())), + layout_resize_owner, + layout_resize_transaction: Arc::new(AtomicU64::new(1)), #[cfg(test)] surface_attach_after_obsolete_check: Arc::new(Mutex::new(None)), } @@ -1702,7 +1742,51 @@ impl OrderedSession { rows: u16, reassert: bool, claim: SurfaceResizeClaim, - ) { + ) -> bool { + self.resize_surface_with_key( + surface_id, + surface, + cols, + rows, + reassert, + claim, + "resize PTY surface", + ("surface resize", surface_id, 0), + ) + } + + fn reclaim_surface_size( + &self, + surface_id: SurfaceId, + surface: SurfaceHandle, + cols: u16, + rows: u16, + claim: SurfaceResizeClaim, + ) -> bool { + self.resize_surface_with_key( + surface_id, + surface, + cols, + rows, + true, + claim, + "reclaim visible PTY surface sizing", + ("surface size release", surface_id, 0), + ) + } + + #[allow(clippy::too_many_arguments)] + fn resize_surface_with_key( + &self, + surface_id: SurfaceId, + surface: SurfaceHandle, + cols: u16, + rows: u16, + reassert: bool, + claim: SurfaceResizeClaim, + label: &'static str, + key: (&'static str, SurfaceId, u64), + ) -> bool { let pending = self.pending_mutation(); let failures = self.surface_resize_failures.clone(); let enqueue_failures = failures.clone(); @@ -1710,8 +1794,8 @@ impl OrderedSession { let superseded = pending.clone(); let settlement = pending.clone(); let enqueue_result = self.operations.enqueue_coalescing_mutation_with_settlement( - "resize PTY surface", - ("surface resize", surface_id, 0), + label, + key, self.remote, move || superseded.supersede(), move || settlement.publish_deferred(), @@ -1760,6 +1844,7 @@ impl OrderedSession { let state = next_surface_sync_failure(previous, transient, false); failures.insert(surface_id, SurfaceResizeFailure { desired: (cols, rows), state }); } + enqueue_result == PtyInputEnqueueResult::Accepted } fn confirm_surface_resize( @@ -1822,6 +1907,29 @@ impl OrderedSession { surface_id: SurfaceId, desired: (u16, u16), surface_needs_resize: bool, + ) -> SurfaceResizeDecision { + self.surface_resize_decision_with_claim_policy( + surface_id, + desired, + surface_needs_resize, + false, + ) + } + + fn surface_resize_reclaim_decision( + &self, + surface_id: SurfaceId, + desired: (u16, u16), + ) -> SurfaceResizeDecision { + self.surface_resize_decision_with_claim_policy(surface_id, desired, true, true) + } + + fn surface_resize_decision_with_claim_policy( + &self, + surface_id: SurfaceId, + desired: (u16, u16), + surface_needs_resize: bool, + supersede_existing_claim: bool, ) -> SurfaceResizeDecision { let mut failures = self.surface_resize_failures.lock().unwrap(); if let Some(failure) = failures.get(&surface_id).copied() @@ -1835,7 +1943,9 @@ impl OrderedSession { } drop(failures); let mut claims = self.surface_resize_claims.lock().unwrap(); - if claims.get(&surface_id).is_some_and(|claim| claim.desired == desired) { + if !supersede_existing_claim + && claims.get(&surface_id).is_some_and(|claim| claim.desired == desired) + { return SurfaceResizeDecision::AlreadyClaimed; } if !claims.contains_key(&surface_id) && !surface_needs_resize { @@ -2009,11 +2119,11 @@ impl OrderedSession { Ok(()) } - pub fn close_screen(&self, screen: cmux_tui_core::ScreenId) { + pub fn close_screen(&self, screen: ScreenId) { self.enqueue_routing("close screen", move |session| session.close_screen(screen)); } - pub fn rename_screen(&self, screen: cmux_tui_core::ScreenId, name: String) { + pub fn rename_screen(&self, screen: ScreenId, name: String) { self.enqueue("rename screen", move |session| session.rename_screen(screen, name)); } @@ -2051,20 +2161,95 @@ impl OrderedSession { Ok(()) } + pub fn new_pane_right( + &self, + pane: PaneId, + width: f32, + size: Option<(u16, u16)>, + ) -> anyhow::Result<()> { + self.enqueue_with_completion( + localization::catalog().layout.create_viewport_pane_operation, + true, + move |session| { + let surface = session.new_pane_right(pane, width, size)?; + Ok(Some(SessionCompletionAction::SurfaceCreated { surface })) + }, + ); + Ok(()) + } + + pub fn undo_layout( + &self, + pane: PaneId, + revision: Option, + confirm_close: bool, + ) -> anyhow::Result<()> { + self.enqueue_with_completion( + localization::catalog().layout.undo_layout_operation, + true, + move |session| { + let result = match session.undo_layout(pane, revision, confirm_close) { + Ok(result) => result, + Err(error) => { + if let Some(action) = layout_undo_error_completion(&error) { + return Ok(Some(action)); + } + return Err(error); + } + }; + match result { + LayoutUndoResult::Undone { .. } => Ok(None), + LayoutUndoResult::ConfirmationRequired { revision, closes_panes, .. } => { + Ok(Some(SessionCompletionAction::LayoutUndoConfirmation { + pane, + revision, + closes_panes, + })) + } + } + }, + ); + Ok(()) + } + pub fn set_split_ratio(&self, split: SplitId, ratio: f32) { self.set_split_ratio_deferred(split, ratio); self.settle_split_ratio(); } + pub fn set_viewport_pane_width(&self, pane: PaneId, width: f32) { + self.set_viewport_pane_width_deferred(pane, width); + self.settle_split_ratio(); + } + fn set_split_ratio_deferred(&self, split: SplitId, ratio: f32) { + let owner = self.layout_resize_owner; + let transaction = self.layout_resize_transaction.load(Ordering::Acquire); + self.enqueue_coalescing_session_mutation( + localization::catalog().layout.resize_exact_split_operation, + (localization::catalog().layout.split_id_subject, split), + move |session| session.set_split_ratio_in_transaction(split, ratio, owner, transaction), + ); + } + + fn set_viewport_pane_width_deferred(&self, pane: PaneId, width: f32) { + let owner = self.layout_resize_owner; + let transaction = self.layout_resize_transaction.load(Ordering::Acquire); self.enqueue_coalescing_session_mutation( - "resize exact pane split", - ("split id", split), - move |session| session.set_split_ratio(split, ratio), + localization::catalog().layout.resize_viewport_pane_operation, + (localization::catalog().layout.viewport_pane_subject, pane), + move |session| { + session.set_viewport_pane_width_in_transaction(pane, width, owner, transaction) + }, ); } fn settle_split_ratio(&self) { + let _ = self.layout_resize_transaction.fetch_update( + Ordering::AcqRel, + Ordering::Acquire, + |transaction| Some(transaction.wrapping_add(1).max(1)), + ); self.enqueue("settle split resize", |_| Ok(())); } @@ -2318,7 +2503,7 @@ pub enum Hit { /// Status-bar screen entry. ScreenEntry { index: usize, - id: cmux_tui_core::ScreenId, + id: ScreenId, }, NewScreen, /// Pane tab-bar entry. @@ -2337,6 +2522,10 @@ pub enum Hit { surface: SurfaceId, track: Rect, }, + /// Client-local horizontal pane-column viewport. + HorizontalScrollbar { + track: Rect, + }, /// The workspace rail's row viewport scrollbar. WorkspaceScrollbar { track: Rect, @@ -2414,6 +2603,83 @@ pub struct PaneArea { pub content: Rect, /// Scrollbar track (inside the box or on the right border). pub track: Option, + /// Horizontal crop applied by a screen viewport. + pub viewport: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct PaneViewportClip { + /// First logical pane column represented by `rect`. + pub rect_source_x: u16, + pub full_rect_width: u16, + /// First logical browser-toolbar column represented by `omnibar`. + pub omnibar_source_x: u16, + pub full_omnibar_width: u16, + /// First logical terminal/browser-content column represented by `content`. + pub content_source_x: u16, + pub full_content_width: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PaneSizeLease { + pane: PaneId, + surface: SurfaceId, + content_size: (u16, u16), +} + +impl PaneArea { + pub(crate) fn logical_rect(&self) -> Rect { + Rect { + width: self.viewport.map_or(self.rect.width, |clip| clip.full_rect_width), + ..self.rect + } + } + + pub(crate) fn content_size(&self) -> (u16, u16) { + ( + self.viewport.map_or(self.content.width, |clip| clip.full_content_width), + self.content.height, + ) + } + + pub(crate) fn content_source_x(&self) -> u16 { + self.viewport.map_or(0, |clip| clip.content_source_x) + } + + pub(crate) fn omnibar_source_x(&self) -> u16 { + self.viewport.map_or(0, |clip| clip.omnibar_source_x) + } + + pub(crate) fn full_omnibar_width(&self) -> u16 { + self.viewport.map_or_else( + || self.omnibar.map_or(0, |rect| rect.width), + |clip| clip.full_omnibar_width, + ) + } + + pub(crate) fn logical_content_rect(&self) -> Rect { + Rect { width: self.content_size().0, ..self.content } + } + + pub(crate) fn logical_content_point(&self, x: u16, y: u16) -> (u16, u16) { + let source_x = self.content_source_x(); + let logical_x = if x >= self.content.x { + self.content.x.saturating_add(source_x).saturating_add(x - self.content.x) + } else { + self.content.x.saturating_add(source_x).saturating_sub(self.content.x - x) + }; + (logical_x, y) + } + + pub(crate) fn has_left_edge(&self) -> bool { + self.viewport.is_none_or(|clip| clip.rect_source_x == 0) + } + + pub(crate) fn has_right_edge(&self) -> bool { + self.viewport.is_none_or(|clip| { + clip.rect_source_x.saturating_add(self.rect.width) >= clip.full_rect_width + }) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2438,8 +2704,8 @@ pub enum MenuAction { DeleteManagedWorkspace(WorkspaceId), RestoreManagedWorkspace(usize), PurgeManagedWorkspace(usize), - RenameScreen(cmux_tui_core::ScreenId), - CloseScreen(cmux_tui_core::ScreenId), + RenameScreen(ScreenId), + CloseScreen(ScreenId), BrowserBack(PaneId), BrowserForward(PaneId), BrowserReload(PaneId), @@ -2991,11 +3257,12 @@ pub enum PromptTarget { Workspace(WorkspaceId), ManagedWorkspace(WorkspaceId), ConfirmPurgeManagedWorkspace(usize), - Screen(cmux_tui_core::ScreenId), + Screen(ScreenId), Surface(SurfaceId), ConnectMachine(MachineConnectRoute), ProviderAction(usize), ConfirmProviderAction, + ConfirmLayoutUndo { pane: PaneId, revision: u64 }, } /// Centered rename dialog: a text input with OK/Cancel buttons. The @@ -3197,6 +3464,29 @@ pub struct TabDragView { pub target: Option<(PaneId, usize)>, } +#[derive(Debug, Clone, Copy)] +enum PaneResizeDragTarget { + ViewportColumn { + pane: PaneId, + edge: PaneEdge, + column_x: u64, + viewport_x: u16, + viewport_width: u16, + /// Frozen at mouse-down so layout reveal motion cannot shift the drag origin. + viewport_offset: u64, + }, + Split { + split: SplitId, + edge: PaneEdge, + area: VirtualRect, + minimum_ratio: f32, + maximum_ratio: f32, + viewport_x: u16, + /// Frozen at mouse-down so layout reveal motion cannot shift the drag origin. + viewport_offset: u64, + }, +} + /// Mouse drag in progress. enum Drag { /// Left press on a machine entry; switching occurs on release. @@ -3210,7 +3500,7 @@ enum Drag { /// Workspace drag with the current insertion index. Workspace { workspace: WorkspaceId, target: Option }, /// Text selection inside a pane's content rect. - Select { content: Rect, auto_scroll: Option, col: u16 }, + Select { content: Rect, source_x: u16, auto_scroll: Option, col: u16 }, /// Browser mouse drag inside a pane's content rect. Browser { surface: SurfaceId, content: Rect, position: (u16, u16) }, /// Mouse reporting owned by the PTY application in this pane. @@ -3226,6 +3516,8 @@ enum Drag { }, /// Scrollbar thumb drag. Scrollbar { surface: SurfaceId, track: Rect, anchor_y: u16, anchor_offset: u64 }, + /// Horizontal pane-column scrollbar drag. + HorizontalScrollbar { track: Rect, anchor_x: u16, anchor_offset: u64 }, /// Workspace viewport scrollbar thumb drag. WorkspaceScrollbar { track: Rect, @@ -3237,7 +3529,7 @@ enum Drag { /// Independent rail width override drag. RailResize(RailKind), /// Pane split resize drag. - ResizeSplit { horizontal: Option<(PaneId, PaneEdge)>, vertical: Option<(PaneId, PaneEdge)> }, + ResizeSplit { horizontal: Option, vertical: Option }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -3283,96 +3575,627 @@ struct PaneFocusHistory { membership_initialized: bool, } -impl PaneFocusHistory { - fn record(&mut self, pane: PaneId) { - self.next_sequence = self.next_sequence.saturating_add(1); - self.recency.insert(pane, self.next_sequence); - } +const VIEWPORT_ANIMATION_DURATION: Duration = Duration::from_millis(180); +// Animated prefetch is optional work on the same 512-entry ordered lane as +// terminal input. Keep it to one eighth of that lane so a focus jump cannot +// crowd out its destination or subsequent input. +const VIEWPORT_ANIMATION_SYNC_OPERATION_BUDGET: usize = PTY_OPERATION_QUEUE_CAPACITY / 8; - fn recency(&self, pane: PaneId) -> (bool, u64) { - self.recency - .get(&pane) - .copied() - .map(|sequence| (true, sequence)) - .unwrap_or_else(|| (false, self.baseline.get(&pane).copied().unwrap_or_default())) +#[cfg(test)] +thread_local! { + static PANE_AREA_PROJECTION_WORK: std::cell::Cell = const { + std::cell::Cell::new(0) + }; +} + +#[cfg(test)] +fn reset_pane_area_projection_work() { + PANE_AREA_PROJECTION_WORK.set(0); +} + +#[cfg(test)] +fn record_pane_area_projection_work(amount: usize) { + PANE_AREA_PROJECTION_WORK.set(PANE_AREA_PROJECTION_WORK.get().saturating_add(amount)); +} + +#[cfg(test)] +fn pane_area_projection_work() -> usize { + PANE_AREA_PROJECTION_WORK.get() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ViewportGeometry { + active_span: Option<(u64, u64)>, + viewport_x: u64, + viewport_width: u16, + virtual_width: u64, +} + +#[derive(Debug)] +struct ViewportMotion { + current: f64, + start: f64, + target: f64, + started_at: Instant, + last_active_pane: Option, + last_geometry: Option, +} + +impl ViewportMotion { + fn new(now: Instant) -> Self { + Self { + current: 0.0, + start: 0.0, + target: 0.0, + started_at: now, + last_active_pane: None, + last_geometry: None, + } } - fn reconcile_membership(&mut self, tree: &TreeView) { - let live = tree - .workspaces - .iter() - .flat_map(|workspace| workspace.screens.iter()) - .flat_map(|screen| screen.panes.iter()) - .map(|pane| pane.id) - .collect::>(); - self.recency.retain(|pane, _| live.contains(pane)); - self.baseline.retain(|pane, _| live.contains(pane)); - for pane in tree - .workspaces - .iter() - .flat_map(|workspace| workspace.screens.iter()) - .flat_map(|screen| screen.panes.iter()) - { - self.baseline.entry(pane.id).or_insert(pane.focused_at); + fn update(&mut self, now: Instant) -> bool { + if (self.current - self.target).abs() < f64::EPSILON { + return false; } - self.membership_revision = tree.pane_revision; - self.membership_initialized = true; + let progress = (now.saturating_duration_since(self.started_at).as_secs_f64() + / VIEWPORT_ANIMATION_DURATION.as_secs_f64()) + .clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - progress).powi(3); + let previous = self.current; + self.current = self.start + (self.target - self.start) * eased; + if progress >= 1.0 { + self.current = self.target; + } + (self.current - previous).abs() >= 0.01 } - fn sync_membership(&mut self, tree: &TreeView) { - if !self.membership_initialized - || tree.pane_revision.is_some() && self.membership_revision != tree.pane_revision - { - self.reconcile_membership(tree); + fn retarget(&mut self, target: u64, animate: bool, now: Instant) { + let target = target as f64; + let _ = self.update(now); + if (self.target - target).abs() < f64::EPSILON { + if !animate { + self.current = target; + self.start = target; + } + return; + } + if animate { + self.start = self.current; + self.target = target; + self.started_at = now; + } else { + self.current = target; + self.start = target; + self.target = target; + self.started_at = now; } } + + fn animating(&self) -> bool { + (self.current - self.target).abs() >= 0.01 + } + + fn offset(&self) -> u64 { + self.current.round().clamp(0.0, u64::MAX as f64) as u64 + } } -pub struct App { - pub session: OrderedSession, - session_event_worker: Option, - session_generation: u64, - app_events: SyncSender, - machine_action_worker: Option, - machine_action_in_flight: bool, - machine_action_request: Option, - machine_provider_reconnect_attempts: u8, - machine_provider_reconnect_retry_at: Option, - pending_machine_replacement: Option, - machine_update_pump: Option, - machine_update_generation: u64, - durable_notices: VecDeque, - recent_durable_notices: VecDeque, - painted_durable_notice_this_frame: Option, - pending_durable_notice_acks: VecDeque, - durable_notice_ack_in_flight: Option, - durable_notice_ack_failures: u8, - durable_notice_ack_retry_at: Option, - pub config: Config, - pub chrome: ChromeTheme, - default_colors: cmux_tui_core::DefaultColors, - pub tree: TreeView, - tab_locations: HashMap, - pub render_states: HashMap, - /// Terminal grid dimensions from the frame actually drawn for each - /// surface. Pointer routing uses this snapshot so resize transitions do - /// not target blank pane margins or wait on the PTY's terminal lock. - pub rendered_terminal_sizes: HashMap, - desired_outer_cursor: OuterCursorSpec, - applied_outer_cursor: Option, - pub graphics_writer: Option, - pub graphics_supported: bool, - stdout_lock: Arc, - pub pane_areas: Vec, - pane_focus_history: PaneFocusHistory, - /// Terminal cells actually represented by the last rendered snapshot. - /// Foreign-viewer padding outside these bounds is display-only. - pub(crate) rendered_terminal_bounds: HashMap, - /// Surfaces whose active tabs were visible in the previous layout pass. - /// Attach streams may outlive this set, but only members hold size leases. - visible_size_surfaces: HashSet, - /// Hidden leases stay owned until the server confirms their idempotent - /// release. Failures clear this set so a later layout pass retries them. +fn clip_horizontal_rect( + rect: VirtualRect, + viewport_x: u64, + viewport_width: u16, + output_x: u16, +) -> Option<(Rect, u16)> { + let left = rect.x.max(viewport_x); + let right = + rect.x.saturating_add(rect.width).min(viewport_x.saturating_add(u64::from(viewport_width))); + if left >= right { + return None; + } + let visible_width = u16::try_from(right - left).ok()?; + let source_x = u16::try_from(left - rect.x).ok()?; + Some(( + Rect { + x: output_x.saturating_add(u16::try_from(left - viewport_x).ok()?), + y: rect.y, + width: visible_width, + height: rect.height, + }, + source_x, + )) +} + +fn terminal_rect_from_virtual(rect: VirtualRect) -> Option { + Some(Rect { + x: u16::try_from(rect.x).ok()?, + y: rect.y, + width: u16::try_from(rect.width).ok()?, + height: rect.height, + }) +} + +fn virtualize_local_rect(parent_x: u64, rect: Rect) -> VirtualRect { + VirtualRect { + x: parent_x.saturating_add(u64::from(rect.x)), + y: rect.y, + width: u64::from(rect.width), + height: rect.height, + } +} + +type PaneParts = (Option, Option, T, Option); + +fn pane_parts_for_virtual_rect( + rect: VirtualRect, + scrollbar_position: ScrollbarPosition, + has_browser_omnibar: bool, +) -> Option> { + let local = + Rect { x: 0, y: rect.y, width: u16::try_from(rect.width).ok()?, height: rect.height }; + let (bar, omnibar, content, track) = + pane_parts_for_rect(local, scrollbar_position, has_browser_omnibar); + Some(( + bar.map(|part| virtualize_local_rect(rect.x, part)), + omnibar.map(|part| virtualize_local_rect(rect.x, part)), + virtualize_local_rect(rect.x, content), + track.map(|part| virtualize_local_rect(rect.x, part)), + )) +} + +fn stacked_header_parts_for_virtual_rect(rect: VirtualRect) -> Option> { + let local = + Rect { x: 0, y: rect.y, width: u16::try_from(rect.width).ok()?, height: rect.height }; + let (bar, omnibar, content, track) = stacked_header_parts_for_rect(local); + Some(( + bar.map(|part| virtualize_local_rect(rect.x, part)), + omnibar.map(|part| virtualize_local_rect(rect.x, part)), + virtualize_local_rect(rect.x, content), + track.map(|part| virtualize_local_rect(rect.x, part)), + )) +} + +struct PaneAreaProjection<'a> { + screen: &'a ScreenView, + layout: &'a [(PaneId, VirtualRect)], + stacked_headers: &'a HashSet, + area: Rect, + scrollbar_position: ScrollbarPosition, + surface_only: Option, + viewport_offset: Option, +} + +#[derive(Debug, Clone, Copy)] +struct PaneAreaSource { + layout_order: usize, + pane: PaneId, + surface: SurfaceId, + full_rect: VirtualRect, + full_bar: Option, + full_omnibar: Option, + full_content: VirtualRect, + full_track: Option, +} + +/// Cached immutable geometry for paint-only viewport animation frames. +/// +/// `sync_layout` replaces this snapshot whenever authoritative tree, layout, +/// chrome, or terminal geometry changes. Animation frames only query it. +#[derive(Default)] +struct ViewportPaneAreaProjection { + screen: Option, + sources: Vec, + prefix_max_right: Vec, +} + +fn full_pane_parts_for_layout( + pane: &PaneView, + full_rect: VirtualRect, + stacked_headers: &HashSet, + scrollbar_position: ScrollbarPosition, + surface_only: Option, +) -> Option<(SurfaceId, PaneParts)> { + let surface_id = pane.active_surface()?; + let has_browser_omnibar = + pane.tabs.get(pane.active_tab).is_some_and(|tab| tab.kind == SurfaceKind::Browser); + let parts = if surface_only.is_some() { + (None, None, full_rect, None) + } else if stacked_headers.contains(&pane.id) { + stacked_header_parts_for_virtual_rect(full_rect)? + } else { + pane_parts_for_virtual_rect(full_rect, scrollbar_position, has_browser_omnibar)? + }; + Some((surface_id, parts)) +} + +fn pane_area_source( + layout_order: usize, + pane: &PaneView, + full_rect: VirtualRect, + stacked_headers: &HashSet, + scrollbar_position: ScrollbarPosition, + surface_only: Option, +) -> Option { + let (surface, (full_bar, full_omnibar, full_content, full_track)) = full_pane_parts_for_layout( + pane, + full_rect, + stacked_headers, + scrollbar_position, + surface_only, + )?; + Some(PaneAreaSource { + layout_order, + pane: pane.id, + surface, + full_rect, + full_bar, + full_omnibar, + full_content, + full_track, + }) +} + +impl ViewportPaneAreaProjection { + fn clear(&mut self) { + self.screen = None; + self.sources.clear(); + self.prefix_max_right.clear(); + } + + fn is_for_screen(&self, screen: ScreenId) -> bool { + self.screen == Some(screen) + } + + fn rebuild(&mut self, projection: PaneAreaProjection<'_>) { + let PaneAreaProjection { + screen, + layout, + stacked_headers, + scrollbar_position, + surface_only, + .. + } = projection; + self.sources.clear(); + self.prefix_max_right.clear(); + self.screen = Some(screen.id); + + #[cfg(test)] + record_pane_area_projection_work(screen.panes.len()); + let panes = screen.panes.iter().map(|pane| (pane.id, pane)).collect::>(); + self.sources.extend(layout.iter().enumerate().filter_map( + |(layout_order, &(pane_id, full_rect))| { + #[cfg(test)] + record_pane_area_projection_work(1); + if full_rect.width == 0 || full_rect.height == 0 { + return None; + } + pane_area_source( + layout_order, + panes.get(&pane_id).copied()?, + full_rect, + stacked_headers, + scrollbar_position, + surface_only, + ) + }, + )); + self.sources.sort_unstable_by_key(|source| (source.full_rect.x, source.layout_order)); + + let mut maximum_right = 0; + self.prefix_max_right.reserve(self.sources.len()); + for source in &self.sources { + maximum_right = + maximum_right.max(source.full_rect.x.saturating_add(source.full_rect.width)); + self.prefix_max_right.push(maximum_right); + } + } + + fn project_into(&self, pane_areas: &mut Vec, area: Rect, viewport_offset: u64) { + pane_areas.clear(); + let viewport_x = u64::from(area.x).saturating_add(viewport_offset); + let viewport_right = viewport_x.saturating_add(u64::from(area.width)); + let start = self.prefix_max_right.partition_point(|right| *right <= viewport_x); + let end = self.sources.partition_point(|source| source.full_rect.x < viewport_right); + if start >= end { + return; + } + for &source in &self.sources[start..end] { + #[cfg(test)] + record_pane_area_projection_work(1); + if let Some(projected) = project_pane_area(source, area, Some(viewport_offset)) { + pane_areas.push(projected); + } + } + } +} + +fn swept_viewport_size_leases( + projection: PaneAreaProjection<'_>, + target_offset: u64, +) -> Option> { + let PaneAreaProjection { + screen, + layout, + stacked_headers, + area, + scrollbar_position, + surface_only, + viewport_offset, + } = projection; + let current_offset = viewport_offset.unwrap_or(0); + let swept_left = u64::from(area.x).saturating_add(current_offset.min(target_offset)); + let swept_right = u64::from(area.x) + .saturating_add(current_offset.max(target_offset)) + .saturating_add(u64::from(area.width)); + let panes = screen.panes.iter().map(|pane| (pane.id, pane)).collect::>(); + let mut operation_cost = 0usize; + let mut leases = Vec::new(); + for &(pane_id, full_rect) in layout { + let Some(pane) = panes.get(&pane_id).copied() else { continue }; + let Some((surface, (_, _, content, _))) = full_pane_parts_for_layout( + pane, + full_rect, + stacked_headers, + scrollbar_position, + surface_only, + ) else { + continue; + }; + let content_right = content.x.saturating_add(content.width); + if content.height == 0 + || content.width == 0 + || content.x >= swept_right + || content_right <= swept_left + { + continue; + } + // One possible attach per tab and one resize for the active surface. + operation_cost = operation_cost.saturating_add(pane.tabs.len().saturating_add(1)); + if operation_cost > VIEWPORT_ANIMATION_SYNC_OPERATION_BUDGET { + return None; + } + leases.push(PaneSizeLease { + pane: pane_id, + surface, + content_size: (u16::try_from(content.width).unwrap_or(u16::MAX), content.height), + }); + } + Some(leases) +} + +fn visible_pane_size_leases(pane_areas: &[PaneArea]) -> Vec { + pane_areas + .iter() + .filter(|area| area.content.width > 0 && area.content.height > 0) + .map(|area| PaneSizeLease { + pane: area.pane, + surface: area.surface, + content_size: area.content_size(), + }) + .collect() +} + +fn project_pane_area( + source: PaneAreaSource, + area: Rect, + viewport_offset: Option, +) -> Option { + let PaneAreaSource { + pane, + surface, + full_rect, + full_bar, + full_omnibar, + full_content, + full_track, + .. + } = source; + let viewport_x = viewport_offset.map(|offset| u64::from(area.x).saturating_add(offset)); + let (rect, rect_source_x, bar, omnibar, omnibar_source_x, content, content_source_x, track) = + if let Some(viewport_x) = viewport_x { + let (rect, rect_source_x) = + clip_horizontal_rect(full_rect, viewport_x, area.width, area.x)?; + let bar = full_bar.and_then(|rect| { + clip_horizontal_rect(rect, viewport_x, area.width, area.x).map(|(rect, _)| rect) + }); + let (omnibar, omnibar_source_x) = full_omnibar + .and_then(|rect| clip_horizontal_rect(rect, viewport_x, area.width, area.x)) + .map_or((None, 0), |(rect, source_x)| (Some(rect), source_x)); + let (content, content_source_x) = + clip_horizontal_rect(full_content, viewport_x, area.width, area.x).unwrap_or(( + Rect { x: rect.x, y: full_content.y, width: 0, height: full_content.height }, + 0, + )); + let track = full_track.and_then(|rect| { + clip_horizontal_rect(rect, viewport_x, area.width, area.x).map(|(rect, _)| rect) + }); + (rect, rect_source_x, bar, omnibar, omnibar_source_x, content, content_source_x, track) + } else { + let rect = terminal_rect_from_virtual(full_rect)?; + let bar = full_bar.and_then(terminal_rect_from_virtual); + let omnibar = full_omnibar.and_then(terminal_rect_from_virtual); + let content = terminal_rect_from_virtual(full_content)?; + let track = full_track.and_then(terminal_rect_from_virtual); + (rect, 0, bar, omnibar, 0, content, 0, track) + }; + let pane_viewport = viewport_x + .is_some() + .then_some(PaneViewportClip { + rect_source_x, + full_rect_width: u16::try_from(full_rect.width).unwrap_or(u16::MAX), + omnibar_source_x, + full_omnibar_width: full_omnibar + .and_then(|rect| u16::try_from(rect.width).ok()) + .unwrap_or(0), + content_source_x, + full_content_width: u16::try_from(full_content.width).unwrap_or(u16::MAX), + }) + .filter(|clip| clip.rect_source_x > 0 || rect.width < clip.full_rect_width); + Some(PaneArea { pane, surface, rect, bar, omnibar, content, track, viewport: pane_viewport }) +} + +fn rebuild_pane_areas(pane_areas: &mut Vec, projection: PaneAreaProjection<'_>) { + let PaneAreaProjection { + screen, + layout, + stacked_headers, + area, + scrollbar_position, + surface_only, + viewport_offset, + } = projection; + pane_areas.clear(); + #[cfg(test)] + record_pane_area_projection_work(screen.panes.len()); + let panes = screen.panes.iter().map(|pane| (pane.id, pane)).collect::>(); + for (layout_order, &(pane_id, full_rect)) in layout.iter().enumerate() { + #[cfg(test)] + record_pane_area_projection_work(1); + let Some(pane) = panes.get(&pane_id).copied() else { continue }; + let Some(source) = pane_area_source( + layout_order, + pane, + full_rect, + stacked_headers, + scrollbar_position, + surface_only, + ) else { + continue; + }; + if let Some(projected) = project_pane_area(source, area, viewport_offset) { + pane_areas.push(projected); + } + } +} + +fn browser_source_crop( + frame_width: u32, + source_column: u16, + visible_columns: u16, + full_columns: u16, +) -> Option<(u32, u32)> { + if frame_width == 0 || visible_columns == 0 || full_columns == 0 { + return None; + } + let frame_width = u64::from(frame_width); + let full_columns = u64::from(full_columns); + let source_column = u64::from(source_column).min(full_columns); + let end_column = source_column.saturating_add(u64::from(visible_columns)).min(full_columns); + if source_column >= end_column { + return None; + } + let source_x = source_column.saturating_mul(frame_width) / full_columns; + let source_end = end_column.saturating_mul(frame_width).div_ceil(full_columns).min(frame_width); + let source_x = source_x.min(frame_width.saturating_sub(1)); + Some((source_x as u32, source_end.saturating_sub(source_x).max(1) as u32)) +} + +fn browser_frame_source_crop( + frame: &BrowserFrame, + source_column: u16, + visible_columns: u16, + full_columns: u16, +) -> Option<(u32, u32)> { + browser_source_crop(frame.image_width, source_column, visible_columns, full_columns) +} + +impl PaneFocusHistory { + fn record(&mut self, pane: PaneId) { + self.next_sequence = self.next_sequence.saturating_add(1); + self.recency.insert(pane, self.next_sequence); + } + + fn recency(&self, pane: PaneId) -> (bool, u64) { + self.recency + .get(&pane) + .copied() + .map(|sequence| (true, sequence)) + .unwrap_or_else(|| (false, self.baseline.get(&pane).copied().unwrap_or_default())) + } + + fn reconcile_membership(&mut self, tree: &TreeView) { + let live = tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + .map(|pane| pane.id) + .collect::>(); + self.recency.retain(|pane, _| live.contains(pane)); + self.baseline.retain(|pane, _| live.contains(pane)); + for pane in tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .flat_map(|screen| screen.panes.iter()) + { + self.baseline.entry(pane.id).or_insert(pane.focused_at); + } + self.membership_revision = tree.pane_revision; + self.membership_initialized = true; + } + + fn sync_membership(&mut self, tree: &TreeView) { + if !self.membership_initialized + || tree.pane_revision.is_some() && self.membership_revision != tree.pane_revision + { + self.reconcile_membership(tree); + } + } +} + +pub struct App { + pub session: OrderedSession, + session_event_worker: Option, + session_generation: u64, + app_events: SyncSender, + machine_action_worker: Option, + machine_action_in_flight: bool, + machine_action_request: Option, + machine_provider_reconnect_attempts: u8, + machine_provider_reconnect_retry_at: Option, + pending_machine_replacement: Option, + machine_update_pump: Option, + machine_update_generation: u64, + durable_notices: VecDeque, + recent_durable_notices: VecDeque, + painted_durable_notice_this_frame: Option, + pending_durable_notice_acks: VecDeque, + durable_notice_ack_in_flight: Option, + durable_notice_ack_failures: u8, + durable_notice_ack_retry_at: Option, + pub config: Config, + pub chrome: ChromeTheme, + default_colors: cmux_tui_core::DefaultColors, + pub tree: TreeView, + tab_locations: HashMap, + pub render_states: HashMap, + pub(crate) chrome_row_scratch: ReusableRowBuffer, + /// Terminal grid dimensions from the frame actually drawn for each + /// surface. Pointer routing uses this snapshot so resize transitions do + /// not target blank pane margins or wait on the PTY's terminal lock. + pub rendered_terminal_sizes: HashMap, + desired_outer_cursor: OuterCursorSpec, + applied_outer_cursor: Option, + pub graphics_writer: Option, + pub graphics_supported: bool, + stdout_lock: Arc, + pub pane_areas: Vec, + viewport_projection: ViewportPaneAreaProjection, + viewport_layout: Vec<(PaneId, VirtualRect)>, + viewport_stacked_headers: HashSet, + viewport_states: HashMap, + viewport_virtual_width: u64, + viewport_offset: u64, + pane_focus_history: PaneFocusHistory, + /// Terminal cells actually represented by the last rendered snapshot. + /// Foreign-viewer padding outside these bounds is display-only. + pub(crate) rendered_terminal_bounds: HashMap, + /// Surfaces whose active tabs occupy the current viewport or an active + /// animation sweep. Attach streams may outlive this set, but only members + /// hold size leases. + visible_size_surfaces: HashSet, + /// Hidden leases stay owned until the server confirms their idempotent + /// release. Failures clear this set so a later layout pass retries them. pending_size_releases: HashSet, pub prefix_armed: bool, pub session_label: String, @@ -3489,7 +4312,14 @@ fn preserve_client_view(previous: &TreeView, next: &mut TreeView) { else { continue; }; - if next_screen.panes.iter().any(|pane| pane.id == previous_screen.active_pane) { + if let Some(zoomed_pane) = next_screen + .zoomed_pane + .filter(|zoomed| next_screen.panes.iter().any(|pane| pane.id == *zoomed)) + { + next_screen.active_pane = zoomed_pane; + } else if next_screen.zoomed_pane.is_none() + && next_screen.panes.iter().any(|pane| pane.id == previous_screen.active_pane) + { next_screen.active_pane = previous_screen.active_pane; } @@ -3722,7 +4552,7 @@ fn pane_parts_for_rect( rect: Rect, scrollbar: ScrollbarPosition, browser_omnibar: bool, -) -> (Option, Option, Rect, Option) { +) -> PaneParts { let (bar, mut content, track) = if rect.width > 2 && rect.height > 2 { let reserved_cols = match scrollbar { ScrollbarPosition::Column => 3, @@ -3763,7 +4593,7 @@ fn pane_parts_for_rect( (bar, omnibar, content, track) } -fn stacked_header_parts_for_rect(rect: Rect) -> (Option, Option, Rect, Option) { +fn stacked_header_parts_for_rect(rect: Rect) -> PaneParts { (Some(rect), None, Rect { y: rect.y.saturating_add(rect.height), height: 0, ..rect }, None) } @@ -4369,14 +5199,16 @@ pub fn run_with_machine_updates( .map(|controller| MachineActionWorker::spawn(controller, tx.clone())) .transpose()?; - // Crossterm input → app channel. - let mut host_keyboard_protocol = HostKeyboardProtocolOwnership::default(); enable_raw_mode()?; + let mut terminal_restore = TerminalRestoreGuard::new(stdout_lock.clone()); if let Err(e) = (|| -> anyhow::Result<()> { let _guard = stdout_lock.lock(); let mut stdout = std::io::stdout(); stdout.execute(EnterAlternateScreen)?; - negotiate_host_keyboard_protocol(&mut stdout, &mut host_keyboard_protocol)?; + negotiate_host_keyboard_protocol( + &mut stdout, + terminal_restore.host_keyboard_protocol_mut(), + )?; stdout.execute(EnableMouseCapture)?; // Ask the host terminal to report Shift-modified mouse events so // Shift remains cmux's selection/context-menu escape while the inner @@ -4386,8 +5218,7 @@ pub fn run_with_machine_updates( stdout.execute(EnableBracketedPaste)?; Ok(()) })() { - let _ = restore_terminal(Some(&stdout_lock), &host_keyboard_protocol); - return Err(e); + return Err(terminal_restore.restore_after_error(e)); } let cell_pixels = crate::ui::graphics::detect_cell_pixels(None, true); @@ -4399,19 +5230,33 @@ pub fn run_with_machine_updates( // Crossterm input → app channel. Start this after startup terminal // probes so DA / window-size responses are not consumed as key input. let input_tx = tx.clone(); - std::thread::Builder::new().name("input".into()).spawn({ - move || { - while let Ok(event) = crossterm::event::read() { - if input_tx.send(AppEvent::Input(event)).is_err() { - break; - } - } + if let Err(error) = std::thread::Builder::new() + .name("input".into()) + .spawn(move || forward_host_input(crossterm::event::read, &input_tx)) + { + return Err(terminal_restore.restore_after_error(error.into())); + } + + let backend = CrosstermBackend::new(std::io::stdout()); + let mut terminal = match RatatuiTerminal::new(backend) { + Ok(terminal) => terminal, + Err(e) => { + return Err(terminal_restore.restore_after_error(e.into())); } - })?; + }; + let graphics_writer = if graphics_supported { + match GraphicsWriter::spawn(stdout_lock.clone()) { + Ok(writer) => Some(writer), + Err(error) => return Err(terminal_restore.restore_after_error(error.into())), + } + } else { + None + }; + // Restore the host terminal even if we panic mid-frame. let default_hook = std::panic::take_hook(); let restore_lock = stdout_lock.clone(); - let panic_keyboard_protocol = host_keyboard_protocol.clone(); + let panic_keyboard_protocol = terminal_restore.host_keyboard_protocol().clone(); std::panic::set_hook(Box::new(move |info| { with_panic_stdout_lock(&restore_lock, || { let _ = restore_terminal_unlocked(&panic_keyboard_protocol); @@ -4419,17 +5264,6 @@ pub fn run_with_machine_updates( default_hook(info); })); - let backend = CrosstermBackend::new(std::io::stdout()); - let mut terminal = match RatatuiTerminal::new(backend) { - Ok(terminal) => terminal, - Err(e) => { - let _ = restore_terminal(Some(&stdout_lock), &host_keyboard_protocol); - return Err(e.into()); - } - }; - let graphics_writer = - if graphics_supported { Some(GraphicsWriter::spawn(stdout_lock.clone())?) } else { None }; - let sidebar_view = config.sidebar.view; let fallback_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let initial_machine_notice = machine_ui.as_ref().and_then(|machine| machine.notice.clone()); @@ -4459,13 +5293,20 @@ pub fn run_with_machine_updates( tree: TreeView::default(), tab_locations: HashMap::new(), render_states: HashMap::new(), + chrome_row_scratch: ReusableRowBuffer::default(), rendered_terminal_sizes: HashMap::new(), desired_outer_cursor: OuterCursorSpec::Reset, applied_outer_cursor: None, graphics_writer, graphics_supported, - stdout_lock: stdout_lock.clone(), + stdout_lock, pane_areas: Vec::new(), + viewport_projection: ViewportPaneAreaProjection::default(), + viewport_layout: Vec::new(), + viewport_stacked_headers: HashSet::new(), + viewport_states: HashMap::new(), + viewport_virtual_width: 0, + viewport_offset: 0, pane_focus_history: PaneFocusHistory::default(), rendered_terminal_bounds: HashMap::new(), visible_size_surfaces: HashSet::new(), @@ -4553,8 +5394,7 @@ pub fn run_with_machine_updates( writer.shutdown(Duration::from_millis(200)); } let _ = std::panic::take_hook(); - let _ = restore_terminal(Some(&stdout_lock), &host_keyboard_protocol); - return Err(error); + return Err(terminal_restore.restore_after_error(error)); } let result = app.event_loop(&mut terminal, rx); @@ -4566,8 +5406,14 @@ pub fn run_with_machine_updates( writer.shutdown(Duration::from_millis(200)); } let _ = std::panic::take_hook(); - restore_terminal(Some(&stdout_lock), &host_keyboard_protocol)?; - result?; + let restore_result = terminal_restore.restore(); + match (result, restore_result) { + (Ok(()), Ok(())) => {} + (Ok(()), Err(error)) | (Err(error), Ok(())) => return Err(error), + (Err(error), Err(restore_error)) => { + return Err(terminal_restore_error(error, restore_error)); + } + } let outcome = app .machine_ui .and_then(|machine| machine.request) @@ -4576,30 +5422,88 @@ pub fn run_with_machine_updates( Ok(outcome) } -#[derive(Clone, Debug)] -struct HostKeyboardProtocolOwnership { - // Panic and normal cleanup share this claim. The stdout lock serializes - // restore attempts, and the first successful pop consumes ownership. - pushed: Arc, +struct TerminalRestoreGuard { + stdout_lock: Arc, + host_keyboard_protocol: HostKeyboardProtocolOwnership, + armed: bool, } -impl Default for HostKeyboardProtocolOwnership { - fn default() -> Self { - Self { pushed: Arc::new(AtomicBool::new(false)) } +impl TerminalRestoreGuard { + fn new(stdout_lock: Arc) -> Self { + Self { + stdout_lock, + host_keyboard_protocol: HostKeyboardProtocolOwnership::default(), + armed: true, + } } -} -impl PartialEq for HostKeyboardProtocolOwnership { - fn eq(&self, other: &Self) -> bool { - self.is_pushed() == other.is_pushed() + fn host_keyboard_protocol(&self) -> &HostKeyboardProtocolOwnership { + &self.host_keyboard_protocol } -} -impl Eq for HostKeyboardProtocolOwnership {} + fn host_keyboard_protocol_mut(&mut self) -> &mut HostKeyboardProtocolOwnership { + &mut self.host_keyboard_protocol + } -impl HostKeyboardProtocolOwnership { - fn pushed() -> Self { - Self { pushed: Arc::new(AtomicBool::new(true)) } + fn restore(&mut self) -> anyhow::Result<()> { + if !self.armed { + return Ok(()); + } + self.armed = false; + restore_terminal(Some(&self.stdout_lock), &self.host_keyboard_protocol) + } + + fn restore_after_error(&mut self, error: anyhow::Error) -> anyhow::Error { + match self.restore() { + Ok(()) => error, + Err(restore_error) => terminal_restore_error(error, restore_error), + } + } +} + +fn terminal_restore_error(error: anyhow::Error, restore_error: anyhow::Error) -> anyhow::Error { + let error = format!("{error:#}"); + let restore_error = format!("{restore_error:#}"); + anyhow::anyhow!( + localization::catalog().runtime.terminal_restore_also_failed(&error, &restore_error) + ) +} + +impl Drop for TerminalRestoreGuard { + fn drop(&mut self) { + if self.armed { + self.armed = false; + with_panic_stdout_lock(&self.stdout_lock, || { + let _ = restore_terminal_unlocked(&self.host_keyboard_protocol); + }); + } + } +} + +#[derive(Clone, Debug)] +struct HostKeyboardProtocolOwnership { + // Panic and normal cleanup share this claim. The stdout lock serializes + // restore attempts, and the first successful pop consumes ownership. + pushed: Arc, +} + +impl Default for HostKeyboardProtocolOwnership { + fn default() -> Self { + Self { pushed: Arc::new(AtomicBool::new(false)) } + } +} + +impl PartialEq for HostKeyboardProtocolOwnership { + fn eq(&self, other: &Self) -> bool { + self.is_pushed() == other.is_pushed() + } +} + +impl Eq for HostKeyboardProtocolOwnership {} + +impl HostKeyboardProtocolOwnership { + fn pushed() -> Self { + Self { pushed: Arc::new(AtomicBool::new(true)) } } fn is_pushed(&self) -> bool { @@ -4715,6 +5619,36 @@ fn restore_terminal_unlocked( Ok(()) } +fn forward_host_input( + mut read: impl FnMut() -> std::io::Result, + input_tx: &SyncSender, +) { + loop { + match read() { + Ok(event) => { + if input_tx.send(AppEvent::Input(event)).is_err() { + return; + } + } + Err(error) => { + let _ = input_tx.send(AppEvent::HostInputFailed(error.to_string())); + return; + } + } + } +} + +fn catch_renderer_panic(render: impl FnOnce() -> T) -> anyhow::Result { + std::panic::catch_unwind(AssertUnwindSafe(render)).map_err(|payload| { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or(localization::catalog().runtime.unknown_panic); + anyhow::anyhow!(localization::catalog().runtime.renderer_panicked(message)) + }) +} + fn localized_clear_history_failure(error: &str) -> &'static str { let messages = &localization::catalog().terminal; match error { @@ -4882,7 +5816,9 @@ impl App { { // Block for the first event, then drain whatever queued so a // torrent of pty output coalesces into one frame. - let timeout = if self.shake_frames > 0 + let timeout = if self.viewport_animation_active() { + Duration::from_millis(16) + } else if self.shake_frames > 0 || self.selection_auto_scroll_active() || self.toast.is_some() { @@ -4941,6 +5877,7 @@ impl App { if self.expire_toast() { action = action.merge(RenderAction::Draw); } + action = action.merge(self.advance_viewport_animation(Instant::now())); if self.advance_expired_durable_notice() { action = action.merge(RenderAction::Draw); } @@ -5563,6 +6500,12 @@ impl App { self.rebuild_tab_locations(); self.render_states.clear(); self.pane_areas.clear(); + self.viewport_projection.clear(); + self.viewport_layout.clear(); + self.viewport_stacked_headers.clear(); + self.viewport_states.clear(); + self.viewport_virtual_width = 0; + self.viewport_offset = 0; self.pane_focus_history = PaneFocusHistory::default(); self.pane_focus_history.sync_membership(&self.tree); self.rendered_terminal_bounds.clear(); @@ -5708,9 +6651,52 @@ impl App { self.focus_omnibar_with_buffer(pane, String::new(), false); } } + SessionCompletionAction::LayoutUndoConfirmation { pane, revision, closes_panes } => { + self.cancel_pty_mouse_drag(); + let label = self.layout_undo_confirmation_label(&closes_panes); + self.prompt = Some(Prompt::new( + label, + String::new(), + PromptTarget::ConfirmLayoutUndo { pane, revision }, + )); + } + SessionCompletionAction::LayoutUndoUnavailable => { + self.status_message = + Some(localization::catalog().sidebar.layout_nothing_to_undo.to_string()); + } + SessionCompletionAction::LayoutUndoStale => { + self.status_message = + Some(localization::catalog().sidebar.layout_undo_stale.to_string()); + } } } + fn layout_undo_confirmation_label(&self, closes_panes: &[PaneId]) -> String { + let identities = closes_panes + .iter() + .map(|pane_id| { + self.tree.pane(*pane_id).map_or_else( + || pane_id.to_string(), + |pane| { + let tabs = pane + .tabs + .iter() + .map(|tab| tab.short_id.as_str()) + .collect::>() + .join(", "); + if tabs.is_empty() { + pane.short_id.clone() + } else { + format!("{} [{}]", pane.short_id, tabs) + } + }, + ) + }) + .collect::>() + .join("; "); + localization::catalog().sidebar.confirm_layout_undo.replace("{items}", &identities) + } + fn select_created_surface(&mut self, surface: SurfaceId) { let Some([workspace_index, screen_index, pane_index, tab_index]) = self.tab_locations.get(&surface).copied() @@ -5874,6 +6860,13 @@ impl App { for surface in removed_browsers { self.browser_input.forget_surface(surface); } + let live_screens = tree + .workspaces + .iter() + .flat_map(|workspace| workspace.screens.iter()) + .map(|screen| screen.id) + .collect::>(); + self.viewport_states.retain(|screen, _| live_screens.contains(screen)); self.pane_focus_history.sync_membership(&tree); self.tree = tree; self.sidebar_workspace_selection = selected_workspace @@ -6054,7 +7047,7 @@ impl App { let lock = self.stdout_lock.clone(); let _guard = lock.lock(); self.painted_durable_notice_this_frame = None; - terminal.draw(|f| crate::ui::draw(self, f))?; + catch_renderer_panic(|| terminal.draw(|f| crate::ui::draw(self, f)))??; self.commit_successful_durable_notice_paint(); self.submit_pending_durable_notice_ack(); if let Some(sequence) = @@ -6092,7 +7085,7 @@ impl App { return false; }; surface.kind() == SurfaceKind::Browser - && surface.browser_frame().is_some() + && surface.has_browser_frame() && area.content.width > 0 && area.content.height > 0 } @@ -6131,11 +7124,19 @@ impl App { continue; } let Some(frame) = surface.browser_frame() else { continue }; + let source_crop_px = area.viewport.and_then(|clip| { + browser_frame_source_crop( + &frame, + clip.content_source_x, + area.content.width, + clip.full_content_width, + ) + }); placements.push(GraphicPlacement { surface: area.surface, rect: area.content, - seq: frame.seq, - data_b64: frame.data_b64, + source_crop_px, + frame, }); } placements @@ -6232,6 +7233,114 @@ impl App { Ok(()) } + fn viewport_animation_active(&self) -> bool { + self.config.viewport.animation + && self + .active_screen_id() + .and_then(|screen| self.viewport_states.get(&screen)) + .is_some_and(ViewportMotion::animating) + } + + fn advance_viewport_animation(&mut self, now: Instant) -> RenderAction { + if !self.config.viewport.animation { + return RenderAction::None; + } + let Some(screen) = self.active_screen_id() else { + return RenderAction::None; + }; + let maximum = + self.viewport_virtual_width.saturating_sub(u64::from(self.content_area.width)); + let (offset, settled) = { + let Some(motion) = self.viewport_states.get_mut(&screen) else { + return RenderAction::None; + }; + let was_animating = motion.animating(); + let _ = motion.update(now); + (motion.offset().min(maximum), was_animating && !motion.animating()) + }; + if offset == self.viewport_offset { + return if settled { RenderAction::Draw } else { RenderAction::None }; + } + self.viewport_offset = offset; + self.reclip_viewport_panes(); + if settled { RenderAction::Draw } else { RenderAction::Paint } + } + + fn reclip_viewport_panes(&mut self) { + if self.viewport_layout.is_empty() { + return; + } + let Some(screen) = self.tree.active_screen() else { + self.pane_areas.clear(); + self.viewport_projection.clear(); + return; + }; + if !self.viewport_projection.is_for_screen(screen.id) { + self.viewport_projection.rebuild(PaneAreaProjection { + screen, + layout: &self.viewport_layout, + stacked_headers: &self.viewport_stacked_headers, + area: self.content_area, + scrollbar_position: self.config.scrollbar.position, + surface_only: self.surface_only, + viewport_offset: Some(self.viewport_offset), + }); + } + self.viewport_projection.project_into( + &mut self.pane_areas, + self.content_area, + self.viewport_offset, + ); + } + + fn sync_viewport_motion( + &mut self, + screen: ScreenId, + active_pane: PaneId, + active_rect: Option, + area: Rect, + virtual_width: u64, + now: Instant, + ) -> u64 { + let maximum = virtual_width.saturating_sub(u64::from(area.width)); + let animate = self.config.viewport.animation; + let motion = self.viewport_states.entry(screen).or_insert_with(|| ViewportMotion::new(now)); + let geometry = ViewportGeometry { + active_span: active_rect.map(|rect| (rect.x, rect.width)), + viewport_x: u64::from(area.x), + viewport_width: area.width, + virtual_width, + }; + let reveal_active = + motion.last_active_pane != Some(active_pane) || motion.last_geometry != Some(geometry); + let mut target = (motion.target.round() as u64).min(maximum); + if reveal_active && let Some(rect) = active_rect { + let left = rect.x.saturating_sub(u64::from(area.x)); + let right = left.saturating_add(rect.width); + if left < target { + target = left; + } else if right > target.saturating_add(u64::from(area.width)) { + target = right.saturating_sub(u64::from(area.width)); + } + } + motion.retarget(target.min(maximum), animate, now); + motion.last_active_pane = Some(active_pane); + motion.last_geometry = Some(geometry); + motion.offset().min(maximum) + } + + fn set_viewport_target(&mut self, target: u64, animate: bool) -> bool { + let Some(screen) = self.active_screen_id() else { return false }; + let maximum = + self.viewport_virtual_width.saturating_sub(u64::from(self.content_area.width)); + let target = target.min(maximum); + let now = Instant::now(); + let motion = self.viewport_states.entry(screen).or_insert_with(|| ViewportMotion::new(now)); + let changed = motion.target.round() as u64 != target || motion.offset() != target; + motion.retarget(target, animate && self.config.viewport.animation, now); + changed + } + /// Refresh the tree snapshot, recompute the active screen's layout /// (each pane's border box eats one cell on every side), and push /// content sizes to surfaces. @@ -6272,22 +7381,13 @@ impl App { self.sidebar_workspace_selection.min(self.tree.workspaces.len().saturating_sub(1)); self.sync_sidebar_files_to_focus(false); } - let layout = self - .tree - .active_screen() - .map(|screen| { - if self.surface_only.is_some() { - layout_screen(&Node::Leaf(screen.active_pane), area, Some(screen.active_pane)) - } else if let Some(pane) = screen.zoomed_pane { - layout_screen(&Node::Leaf(pane), area, Some(pane)) - } else { - layout_screen(&screen.layout, area, Some(screen.active_pane)) - } - }) - .unwrap_or_default(); - self.pane_areas.clear(); let Some(screen) = self.tree.active_screen().cloned() else { + self.viewport_projection.clear(); + self.viewport_layout.clear(); + self.viewport_stacked_headers.clear(); + self.viewport_virtual_width = 0; + self.viewport_offset = 0; let hidden = self .visible_size_surfaces .difference(&self.pending_size_releases) @@ -6302,36 +7402,102 @@ impl App { } return; }; - let stacked_headers = layout.stacked_headers; - for (pane_id, rect) in layout.panes { - let Some(pane) = screen.pane(pane_id) else { continue }; - let Some(surface_id) = pane.active_surface() else { continue }; - let has_browser_omnibar = - pane.tabs.get(pane.active_tab).is_some_and(|tab| tab.kind == SurfaceKind::Browser); - let (bar, omnibar, content, track) = if self.surface_only.is_some() { - (None, None, rect, None) - } else if stacked_headers.contains(&pane_id) { - stacked_header_parts_for_rect(rect) + let viewport_enabled = self.surface_only.is_none() + && screen.zoomed_pane.is_none() + && !screen.viewport_splits.is_empty(); + let layout: ViewportLayoutResult = if self.surface_only.is_some() { + layout_screen(&Node::Leaf(screen.active_pane), area, Some(screen.active_pane)).into() + } else if let Some(pane) = screen.zoomed_pane { + layout_screen(&Node::Leaf(pane), area, Some(pane)).into() + } else if viewport_enabled { + layout_screen_with_viewport( + &screen.layout, + area, + Some(screen.active_pane), + screen.viewport_base_width.unwrap_or(1.0), + &screen.viewport_splits, + ) + } else { + layout_screen(&screen.layout, area, Some(screen.active_pane)).into() + }; + let viewport_enabled = viewport_enabled && layout.virtual_width > u64::from(area.width); + self.viewport_layout = if viewport_enabled { layout.panes.clone() } else { Vec::new() }; + self.viewport_stacked_headers = + if viewport_enabled { layout.stacked_headers.clone() } else { HashSet::new() }; + self.viewport_virtual_width = layout.virtual_width; + self.viewport_offset = if viewport_enabled { + self.sync_viewport_motion( + screen.id, + screen.active_pane, + layout.rect_of(screen.active_pane), + area, + layout.virtual_width, + Instant::now(), + ) + } else { + 0 + }; + let pane_projection = PaneAreaProjection { + screen: &screen, + layout: &layout.panes, + stacked_headers: &layout.stacked_headers, + area, + scrollbar_position: self.config.scrollbar.position, + surface_only: self.surface_only, + viewport_offset: viewport_enabled.then_some(self.viewport_offset), + }; + if viewport_enabled { + self.viewport_projection.rebuild(pane_projection); + self.viewport_projection.project_into(&mut self.pane_areas, area, self.viewport_offset); + } else { + self.viewport_projection.clear(); + rebuild_pane_areas(&mut self.pane_areas, pane_projection); + } + + // Size and attach every pane crossed by a short animation up front. + // Paint-only ticks can then reveal cached surfaces without socket + // work. Expensive jumps settle immediately and synchronize only the + // destination viewport instead of saturating the ordered PTY lane. + let target_offset = viewport_enabled.then(|| { + let maximum = layout.virtual_width.saturating_sub(u64::from(area.width)); + self.viewport_states + .get(&screen.id) + .map_or(self.viewport_offset, |motion| motion.target.round() as u64) + .min(maximum) + }); + let size_leases = if let Some(target_offset) = + target_offset.filter(|target| *target != self.viewport_offset) + { + if let Some(leases) = swept_viewport_size_leases( + PaneAreaProjection { + screen: &screen, + layout: &layout.panes, + stacked_headers: &layout.stacked_headers, + area, + scrollbar_position: self.config.scrollbar.position, + surface_only: self.surface_only, + viewport_offset: Some(self.viewport_offset), + }, + target_offset, + ) { + leases } else { - pane_parts_for_rect(rect, self.config.scrollbar.position, has_browser_omnibar) - }; - self.pane_areas.push(PaneArea { - pane: pane_id, - surface: surface_id, - rect, - bar, - omnibar, - content, - track, - }); - } + if let Some(motion) = self.viewport_states.get_mut(&screen.id) { + motion.retarget(target_offset, false, Instant::now()); + } + self.viewport_offset = target_offset; + self.viewport_projection.project_into( + &mut self.pane_areas, + area, + self.viewport_offset, + ); + visible_pane_size_leases(&self.pane_areas) + } + } else { + visible_pane_size_leases(&self.pane_areas) + }; - let visible = self - .pane_areas - .iter() - .filter(|area| area.content.width > 0 && area.content.height > 0) - .map(|area| area.surface) - .collect::>(); + let visible = size_leases.iter().map(|lease| lease.surface).collect::>(); let hidden = self .visible_size_surfaces .difference(&visible) @@ -6346,19 +7512,19 @@ impl App { self.pending_size_releases.insert(surface); } } - let newly_visible = + let resurfaced = + visible.intersection(&self.pending_size_releases).copied().collect::>(); + let mut newly_visible = visible.difference(&self.visible_size_surfaces).copied().collect::>(); - self.visible_size_surfaces.extend(visible); + newly_visible.extend(resurfaced.iter().copied()); + self.visible_size_surfaces.extend(visible.iter().copied()); // Keep inactive tabs attached for instant rendering, but give only - // each pane's active tab a sizing lease. This makes visibility, not - // cached transport state, the shared-size ownership boundary. - for index in 0..self.pane_areas.len() { - let area = self.pane_areas[index]; - if area.content.width == 0 || area.content.height == 0 { - continue; - } - let Some(pane) = screen.pane(area.pane) else { continue }; + // active tabs in the swept viewport a sizing lease. The settling draw + // releases panes outside the final viewport. + for lease in size_leases { + let Some(pane) = screen.pane(lease.pane) else { continue }; + let content_size = lease.content_size; for tab in &pane.tabs { if self.surface_only.is_some_and(|surface| surface != tab.surface) { continue; @@ -6366,8 +7532,8 @@ impl App { if self.session.has_surface(tab.surface) { continue; } - let size = (tab.surface == area.surface) - .then_some((area.content.width, area.content.height)) + let size = (tab.surface == lease.surface) + .then_some(content_size) .filter(|(cols, rows)| *cols > 0 && *rows > 0); if self.session.can_attach_surface(tab.surface) && self.prepare_pty_input_before_mutation() @@ -6375,28 +7541,45 @@ impl App { self.session.attach_surface(tab.surface, size); } } - let Some(surface) = self.session.surface(area.surface) else { continue }; - let desired = (area.content.width, area.content.height); + let Some(surface) = self.session.surface(lease.surface) else { continue }; + let desired = content_size; if surface.kind() == SurfaceKind::Browser - && self.browser_input.resize_failed(area.surface, desired) + && self.browser_input.resize_failed(lease.surface, desired) { continue; } - let needs = newly_visible.contains(&area.surface) - || !self.session.has_surface_size_report(area.surface) - || surface.resize_needed(area.content.width, area.content.height, false); - if let SurfaceResizeDecision::NeedsQueue(claim) = - self.session.surface_resize_decision(area.surface, desired, needs) + let needs = newly_visible.contains(&lease.surface) + || !self.session.has_surface_size_report(lease.surface) + || surface.resize_needed(content_size.0, content_size.1, false); + let resize_decision = if resurfaced.contains(&lease.surface) { + self.session.surface_resize_reclaim_decision(lease.surface, desired) + } else { + self.session.surface_resize_decision(lease.surface, desired, needs) + }; + if let SurfaceResizeDecision::NeedsQueue(claim) = resize_decision && self.prepare_pty_input_before_mutation() { - self.enqueue_surface_resize( - area.surface, - surface, - area.content.width, - area.content.height, - false, - Some(claim), - ); + let accepted = if resurfaced.contains(&lease.surface) { + self.enqueue_surface_size_reclaim( + lease.surface, + surface, + content_size.0, + content_size.1, + Some(claim), + ) + } else { + self.enqueue_surface_resize( + lease.surface, + surface, + content_size.0, + content_size.1, + false, + Some(claim), + ) + }; + if accepted && resurfaced.contains(&lease.surface) { + self.pending_size_releases.remove(&lease.surface); + } } } } @@ -6838,18 +8021,28 @@ impl App { } } SessionMutationOutcome::SurfaceSizeReleased { surface } => { - self.pending_size_releases.remove(&surface); - self.visible_size_surfaces.remove(&surface); + let expected = self.pending_size_releases.remove(&surface); + if expected { + self.visible_size_surfaces.remove(&surface); + } else if self.visible_size_surfaces.remove(&surface) { + // A release already in flight can settle after the + // pane becomes visible. Force the next draw to + // reclaim its lease even if geometry is unchanged. + self.session.invalidate_surface_size_report(surface); + } } SessionMutationOutcome::SurfaceSizeReleaseFailed { surface, error } => { - self.pending_size_releases.remove(&surface); - self.session.invalidate_surface_size_report(surface); - if self.pane_areas.iter().any(|area| area.surface == surface) { - self.visible_size_surfaces.remove(&surface); + if self.pending_size_releases.remove(&surface) { + self.session.invalidate_surface_size_report(surface); + if self.pane_areas.iter().any(|area| area.surface == surface) { + self.visible_size_surfaces.remove(&surface); + } + self.status_message = Some( + localization::catalog() + .layout + .surface_size_release_failed(surface, &error), + ); } - self.status_message = Some(format!( - "surface {surface} size release failed; retrying on the next layout: {error}" - )); } SessionMutationOutcome::SurfaceSizeReleaseCanceled { surface } => { self.pending_size_releases.remove(&surface); @@ -6939,6 +8132,9 @@ impl App { } Ok(RenderAction::Draw) } + AppEvent::HostInputFailed(error) => { + anyhow::bail!(localization::catalog().runtime.host_input_failed(&error)) + } AppEvent::Input(_) => unreachable!("input events are normalized before dispatch"), AppEvent::SessionScoped { .. } => { unreachable!("session-scoped events are unwrapped before dispatch") @@ -7038,7 +8234,7 @@ impl App { Ok(RenderAction::Draw) } TerminalInput::FocusLost => { - self.cancel_pty_mouse_drag(); + self.finish_active_drag(); Ok(RenderAction::None) } TerminalInput::Resize => { @@ -7102,6 +8298,13 @@ impl App { matches!( (input, &self.drag), ( + TerminalInput::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left) + | MouseEventKind::Up(MouseButton::Left), + .. + }), + Some(Drag::HorizontalScrollbar { .. }) + ) | ( TerminalInput::Mouse(MouseEvent { kind: MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left), @@ -7262,7 +8465,7 @@ impl App { .pane_areas .iter() .find(|area| area.surface == surface) - .map(|area| (area.content.width, area.content.height)) + .map(PaneArea::content_size) .filter(|(cols, rows)| *cols > 0 && *rows > 0); self.session.attach_surface(surface, size); } @@ -7275,7 +8478,7 @@ impl App { } } - fn active_screen_id(&self) -> Option { + fn active_screen_id(&self) -> Option { self.tree.active_screen().map(|screen| screen.id) } @@ -7330,23 +8533,18 @@ impl App { { return; } + let desired = area.content_size(); if let Some(surface) = self.session.surface(area.surface) { - let desired = (area.content.width, area.content.height); if surface.kind() == SurfaceKind::Browser && self.browser_input.resize_failed(area.surface, desired) { continue; } - let needs_barrier = - surface.resize_needed(area.content.width, area.content.height, true); - match self.session.surface_resize_decision( - area.surface, - (area.content.width, area.content.height), - needs_barrier, - ) { + let needs_barrier = surface.resize_needed(desired.0, desired.1, true); + match self.session.surface_resize_decision(area.surface, desired, needs_barrier) { SurfaceResizeDecision::Noop => { if surface.kind() != SurfaceKind::Browser { - let _ = surface.reassert_size(area.content.width, area.content.height); + let _ = surface.reassert_size(desired.0, desired.1); } } SurfaceResizeDecision::AlreadyClaimed | SurfaceResizeDecision::Failed => {} @@ -7356,8 +8554,8 @@ impl App { self.enqueue_surface_resize( area.surface, surface, - area.content.width, - area.content.height, + desired.0, + desired.1, true, Some(claim), ); @@ -7367,8 +8565,7 @@ impl App { } else if self.session.can_attach_surface(area.surface) && self.prepare_pty_input_before_mutation() { - self.session - .attach_surface(area.surface, Some((area.content.width, area.content.height))); + self.session.attach_surface(area.surface, Some(desired)); } } } @@ -7380,6 +8577,59 @@ impl App { } } + pub(crate) fn horizontal_scrollbar_state(&self) -> Option<(u64, u16, u64)> { + let viewport_width = self.content_area.width; + if viewport_width == 0 || self.viewport_virtual_width <= u64::from(viewport_width) { + return None; + } + Some((self.viewport_virtual_width, viewport_width, self.viewport_offset)) + } + + fn scroll_horizontal_viewport(&mut self, delta: i16, animate: bool) -> bool { + let Some(screen) = self.active_screen_id() else { return false }; + let Some((content_width, viewport_width, _)) = self.horizontal_scrollbar_state() else { + return false; + }; + let maximum = content_width.saturating_sub(u64::from(viewport_width)); + let current = self + .viewport_states + .get(&screen) + .map_or(self.viewport_offset, |motion| motion.target.round() as u64); + self.set_viewport_target( + current.saturating_add_signed(i64::from(delta)).min(maximum), + animate, + ) + } + + /// Start a horizontal scrollbar drag. Grabbing the thumb freezes it at + /// its rendered offset; clicking the track jumps first and anchors there. + fn start_horizontal_scrollbar_drag(&mut self, track: Rect, x: u16) { + let Some((content_width, viewport_width, offset)) = self.horizontal_scrollbar_state() + else { + return; + }; + if track.width == 0 { + return; + } + let relative = x.saturating_sub(track.x).min(track.width - 1); + let (thumb_x, thumb_width) = + horizontal_thumb_geometry(content_width, viewport_width, offset, track.width); + let on_thumb = relative >= thumb_x && relative < thumb_x.saturating_add(thumb_width); + let anchor_offset = if on_thumb { + self.set_viewport_target(offset, false); + offset + } else { + let Some(target) = + horizontal_offset_at(content_width, viewport_width, track.width, relative) + else { + return; + }; + self.set_viewport_target(target, true); + target + }; + self.drag = Some(Drag::HorizontalScrollbar { track, anchor_x: x, anchor_offset }); + } + pub fn dragging_workspace_scrollbar(&self) -> bool { matches!(self.drag, Some(Drag::WorkspaceScrollbar { .. })) } @@ -7392,11 +8642,11 @@ impl App { rows: u16, reassert: bool, claim: Option, - ) { + ) -> bool { if surface.kind() == SurfaceKind::Browser { - let Some(claim) = claim else { return }; + let Some(claim) = claim else { return false }; let ownership = self.session.surface_resize_ownership.clone(); - let _ = self.browser_input.enqueue(BrowserInputEvent { + self.browser_input.enqueue(BrowserInputEvent { surface_id, surface, kind: BrowserInputKind::Resize { @@ -7413,10 +8663,26 @@ impl App { ); })), }, - }); + }) + } else { + let Some(claim) = claim else { return false }; + self.session.resize_surface(surface_id, surface, cols, rows, reassert, claim) + } + } + + fn enqueue_surface_size_reclaim( + &mut self, + surface_id: SurfaceId, + surface: SurfaceHandle, + cols: u16, + rows: u16, + claim: Option, + ) -> bool { + if surface.kind() == SurfaceKind::Browser { + self.enqueue_surface_resize(surface_id, surface, cols, rows, true, claim) } else { - let Some(claim) = claim else { return }; - self.session.resize_surface(surface_id, surface, cols, rows, reassert, claim); + let Some(claim) = claim else { return false }; + self.session.reclaim_surface_size(surface_id, surface, cols, rows, claim) } } @@ -7452,16 +8718,22 @@ impl App { } fn auto_scroll_selection_tick(&mut self) -> bool { - let Some(Drag::Select { content, auto_scroll: Some(dir), col }) = self.drag else { + let Some(Drag::Select { content, source_x, auto_scroll: Some(dir), col }) = self.drag + else { return false; }; let Some(surface_id) = self.selection.map(|sel| sel.surface) else { return false }; let Some(surface) = self.session.surface(surface_id) else { return false }; + let content = + self.current_selection_geometry(surface_id).map_or(content, |(content, _)| content); let moved = surface.scroll_delta(dir as isize).unwrap_or(false); let edge_row = if dir < 0 { 0 } else { content.height.saturating_sub(1) }; let offset = self.surface_scroll_offset(surface_id); if let Some(mut selection) = self.selection { - selection.head = (col.min(content.width.saturating_sub(1)), offset + edge_row as u64); + selection.head = ( + col.min(content.width.saturating_sub(1).saturating_add(source_x)), + offset + edge_row as u64, + ); self.replace_selection(Some(selection)); } moved @@ -7475,7 +8747,7 @@ impl App { /// Size hint for splitting `pane`: the second side of its rect. fn split_size_hint(&self, pane: PaneId, dir: SplitDir) -> Option<(u16, u16)> { let area = self.pane_areas.iter().find(|a| a.pane == pane)?; - let (_, b) = split_sides(area.rect, dir, 0.5); + let (_, b) = split_sides(area.logical_rect(), dir, 0.5); self.size_of_rect(b) } @@ -7494,11 +8766,9 @@ impl App { fn terminal_tab_size_hint(&self, pane: Option) -> Option<(u16, u16)> { match pane { - Some(pane) => self - .pane_areas - .iter() - .find(|area| area.pane == pane) - .and_then(|area| self.size_of_rect(area.rect)), + Some(pane) => { + self.pane_areas.iter().find(|area| area.pane == pane).map(PaneArea::content_size) + } None => self .active_pane() .and_then(|pane| self.terminal_tab_size_hint(Some(pane))) @@ -7513,10 +8783,26 @@ impl App { let Some(hint) = self.tree.active_screen().and_then(|screen| { let mut panes = Vec::new(); screen.layout.pane_ids(&mut panes); + let mut local_area = self.content_area; + if !screen.viewport_splits.is_empty() { + let owner = screen.layout.viewport_column_owner(pane, &screen.viewport_splits)?; + panes.retain(|candidate| { + screen.layout.viewport_column_owner(*candidate, &screen.viewport_splits) + == Some(owner) + }); + let width = match owner { + ViewportColumn::Base => screen.viewport_base_width.unwrap_or(1.0), + ViewportColumn::Split(split) => { + screen.viewport_splits.get(&split).copied().unwrap_or(1.0) + } + }; + local_area.width = + ((f32::from(self.content_area.width) * width).round() as u16).max(1); + } panes.push(PaneId::MAX); let layout = zellij_default_pane_layout(&panes)?; - let rect = layout_screen(&layout, self.content_area, Some(PaneId::MAX)) - .rect_of(PaneId::MAX)?; + let rect = + layout_screen(&layout, local_area, Some(PaneId::MAX)).rect_of(PaneId::MAX)?; self.size_of_rect(rect) }) else { return Ok(()); @@ -7527,6 +8813,21 @@ impl App { self.session.new_pane(pane, Some(hint)) } + fn new_pane_right(&mut self) -> anyhow::Result<()> { + let Some(pane) = self.active_pane() else { + return Ok(()); + }; + let width = ((f32::from(self.content_area.width) * DEFAULT_VIEWPORT_PANE_WIDTH).round() + as u16) + .clamp(1, self.content_area.width.max(1)); + let rect = Rect { width, ..self.content_area }; + let hint = self.size_of_rect(rect); + if self.prepare_pty_input_before_mutation() { + self.session.new_pane_right(pane, DEFAULT_VIEWPORT_PANE_WIDTH, hint)?; + } + Ok(()) + } + fn new_workspace(&mut self) -> anyhow::Result<()> { let Some(mode) = self.default_workspace_creation_mode() else { self.status_message = Some( @@ -8519,6 +9820,23 @@ impl App { } return; } + if let PromptTarget::ConfirmLayoutUndo { pane, revision } = prompt.target { + if input.trim() != "CONFIRM" { + self.status_message = + Some(localization::catalog().sidebar.confirmation_mismatch.to_string()); + self.prompt = Some(prompt); + self.shake_frames = 6; + return; + } + if !self.prepare_pty_input_before_mutation() { + self.prompt = Some(prompt); + return; + } + if let Err(error) = self.session.undo_layout(pane, Some(revision), true) { + self.status_message = Some(error.to_string()); + } + return; + } if !self.prepare_pty_input_before_mutation() { return; } @@ -8538,7 +9856,8 @@ impl App { | PromptTarget::ProviderAction(_) | PromptTarget::ConfirmProviderAction | PromptTarget::ManagedWorkspace(_) - | PromptTarget::ConfirmPurgeManagedWorkspace(_) => { + | PromptTarget::ConfirmPurgeManagedWorkspace(_) + | PromptTarget::ConfirmLayoutUndo { .. } => { unreachable!("handled before session mutation") } } @@ -8949,6 +10268,12 @@ impl App { } Action::ToggleSidebarView => self.toggle_sidebar_view(), Action::FocusSidebar => self.toggle_sidebar_focus(), + Action::NewPaneRight => self.new_pane_right()?, + Action::UndoLayout => { + if let Some(pane) = pane { + self.session.undo_layout(pane, None, false)?; + } + } Action::FocusLeft => self.move_focus(Direction::Left), Action::FocusRight => self.move_focus(Direction::Right), Action::FocusUp => self.move_focus(Direction::Up), @@ -8996,7 +10321,7 @@ impl App { self.shortcut_help = if self.shortcut_help.is_some() { None } else { - self.finish_active_drag_for_overlay(); + self.finish_active_drag(); Some(ShortcutHelp::from_config(&self.config, self.surface_only.is_some())) }; self.menu = None; @@ -9079,7 +10404,7 @@ impl App { fn browser_tab_size_hint(&self, pane: Option) -> Option<(u16, u16)> { match pane { Some(pane) => self.pane_areas.iter().find(|area| area.pane == pane).and_then(|area| { - browser_content_size_for_rect(area.rect, self.config.scrollbar.position) + browser_content_size_for_rect(area.logical_rect(), self.config.scrollbar.position) }), None => self .active_pane() @@ -9411,7 +10736,9 @@ impl App { } fn move_focus(&mut self, direction: Direction) { - let Some(screen) = self.tree.active_screen() else { return }; + let Some(screen) = self.tree.active_screen() else { + return; + }; if screen.zoomed_pane.is_some() { return; } @@ -9422,10 +10749,12 @@ impl App { Direction::Up => (0, -1), Direction::Down => (0, 1), }; - let layout = cmux_tui_core::LayoutResult { - panes: self.pane_areas.iter().map(|area| (area.pane, area.rect)).collect(), - ..Default::default() + let panes = if self.viewport_layout.is_empty() { + self.pane_areas.iter().map(|area| (area.pane, area.rect.into())).collect() + } else { + self.viewport_layout.clone() }; + let layout = ViewportLayoutResult { panes, ..Default::default() }; if let Some(next) = layout.neighbor_by_recency(active, dx, dy, |pane| self.pane_focus_history.recency(pane)) { @@ -9810,7 +11139,8 @@ impl App { .omnibar .as_ref() .is_some_and(|state| state.pane == area.pane && state.surface == area.surface); - crate::ui::omnibar::hit(rect, x, y, editing).map(|hit| (area.pane, hit)) + crate::ui::omnibar::hit(rect, area.omnibar_source_x(), x, y, editing) + .map(|hit| (area.pane, hit)) }) } @@ -10035,8 +11365,9 @@ impl App { { return PtyMousePressResult::NotOwned; } - let content = self.canonical_pty_content(area.surface, area.content); - if !content.contains(x, y) { + let content = self.canonical_pty_content(area.surface, area.logical_content_rect()); + let (logical_x, logical_y) = area.logical_content_point(x, y); + if !content.contains(logical_x, logical_y) { return PtyMousePressResult::NotOwned; } let Some(handle) = self.session.surface(area.surface) else { @@ -10045,8 +11376,8 @@ impl App { let (release_capture, forwarded) = self.prepare_pty_mouse_press( (area.surface, handle.clone()), content, - x, - y, + logical_x, + logical_y, button, modifiers, ); @@ -10078,7 +11409,7 @@ impl App { release_bytes, content, button, - position: (x, y), + position: (logical_x, logical_y), modifiers, }); self.ignored_pty_mouse_buttons.clear(); @@ -10166,7 +11497,7 @@ impl App { self.cancel_pty_mouse_drag(); return true; } - let content = self.current_pty_content(surface).unwrap_or(content); + let (content, x, y) = self.current_pty_pointer(surface, x, y).unwrap_or((content, x, y)); if let Some(Drag::PtyMouse { position, modifiers: stored_modifiers, .. }) = &mut self.drag { *position = (x, y); *stored_modifiers = modifiers; @@ -10207,7 +11538,7 @@ impl App { self.ignored_pty_mouse_buttons.remove(&reported_button); return true; } - let content = self.current_pty_content(surface).unwrap_or(content); + let (content, x, y) = self.current_pty_pointer(surface, x, y).unwrap_or((content, x, y)); let release = self.capture_pty_mouse_release(surface, content, x, y, button, modifiers); self.drag = None; self.ignored_pty_mouse_buttons.clear(); @@ -10330,13 +11661,18 @@ impl App { } } - fn finish_active_drag_for_overlay(&mut self) { + fn finish_active_drag(&mut self) { if matches!(self.drag, Some(Drag::PtyMouse { .. })) { self.cancel_pty_mouse_drag(); return; } match self.drag.take() { Some(Drag::Browser { surface, content, position }) => { + let content = self.current_browser_content(surface).unwrap_or(content); + let position = ( + position.0.clamp(content.x, content.x + content.width.saturating_sub(1)), + position.1.clamp(content.y, content.y + content.height.saturating_sub(1)), + ); self.send_browser_mouse( surface, content, @@ -10354,6 +11690,7 @@ impl App { | Drag::Workspace { .. } | Drag::Select { .. } | Drag::Scrollbar { .. } + | Drag::HorizontalScrollbar { .. } | Drag::WorkspaceScrollbar { .. } | Drag::RailResize(_), ) @@ -10379,13 +11716,14 @@ impl App { { return false; } - let content = self.canonical_pty_content(area.surface, area.content); + let content = self.canonical_pty_content(area.surface, area.logical_content_rect()); + let (logical_x, logical_y) = area.logical_content_point(x, y); if action == MouseAction::Motion { - let inside = content.contains(x, y); + let inside = content.contains(logical_x, logical_y); let owned = self.forward_pty_mouse_motion_if_uncontended( area.surface, content, - (x, y), + (logical_x, logical_y), None, modifiers, any_button_pressed, @@ -10401,14 +11739,14 @@ impl App { } return owned; } - if !content.contains(x, y) { + if !content.contains(logical_x, logical_y) { return false; } self.forward_pty_mouse_to_surface( area.surface, content, - x, - y, + logical_x, + logical_y, action, button, modifiers, @@ -10718,7 +12056,23 @@ impl App { self.pane_areas .iter() .find(|area| area.surface == surface) - .map(|area| self.canonical_pty_content(surface, area.content)) + .map(|area| self.canonical_pty_content(surface, area.logical_content_rect())) + } + + fn current_browser_content(&self, surface: SurfaceId) -> Option { + self.pane_areas.iter().find(|area| area.surface == surface).map(|area| area.content) + } + + fn current_selection_geometry(&self, surface: SurfaceId) -> Option<(Rect, u16)> { + let area = self.pane_areas.iter().find(|area| area.surface == surface)?; + Some((self.terminal_input_rect(area)?, area.content_source_x())) + } + + fn current_pty_pointer(&self, surface: SurfaceId, x: u16, y: u16) -> Option<(Rect, u16, u16)> { + let area = self.pane_areas.iter().find(|area| area.surface == surface)?; + let content = self.canonical_pty_content(surface, area.logical_content_rect()); + let (x, y) = area.logical_content_point(x, y); + Some((content, x, y)) } fn canonical_pty_content(&self, surface: SurfaceId, content: Rect) -> Rect { @@ -10837,7 +12191,10 @@ impl App { (surface.kind() == SurfaceKind::Browser).then(|| surface.browser_status()) }); if browser_hover_forward_allowed(status.flatten(), editing_same_pane) { - let cell = (x.saturating_sub(area.content.x), y.saturating_sub(area.content.y)); + let cell = ( + area.content_source_x().saturating_add(x.saturating_sub(area.content.x)), + y.saturating_sub(area.content.y), + ); let next = (area.surface, cell.0, cell.1); if self.last_browser_hover != Some(next) { self.send_browser_mouse( @@ -10864,6 +12221,7 @@ impl App { Hit::NewTab { .. } | Hit::TabScroll { .. } | Hit::Scrollbar { .. } + | Hit::HorizontalScrollbar { .. } | Hit::WorkspaceScrollbar { .. } ) }) @@ -10921,7 +12279,7 @@ impl App { modifiers: KeyModifiers, ) -> anyhow::Result { self.replace_selection(None); - self.drag = None; + self.finish_active_drag(); if self.pairing_dialog.is_some() { return self.handle_pairing_click(x, y); @@ -10953,11 +12311,11 @@ impl App { if let Some(state) = self.omnibar.as_mut() { if state.pane == pane { if hit == OmnibarHit::Edit - && let Some(rect) = self + && let Some(area) = self .pane_areas .iter() .find(|area| area.pane == pane && area.surface == state.surface) - .and_then(|area| area.omnibar) + && let Some(rect) = area.omnibar { state.select_all = false; state.input.set_cursor_from_visible_column( @@ -11122,6 +12480,9 @@ impl App { Hit::Scrollbar { surface, track } => { self.start_scrollbar_drag(surface, track, y); } + Hit::HorizontalScrollbar { track } => { + self.start_horizontal_scrollbar_drag(track, x); + } Hit::WorkspaceScrollbar { track, total_rows, visible_rows } => { self.focus = FocusTarget::WorkspaceRail; self.workspace_rail_follow_selection = false; @@ -11135,7 +12496,13 @@ impl App { self.drag = Some(Drag::RailResize(kind)); } Hit::PaneResize { horizontal, vertical } => { - self.drag = Some(Drag::ResizeSplit { horizontal, vertical }); + let horizontal = horizontal + .and_then(|(pane, edge)| self.resolve_pane_resize_drag(pane, edge)); + let vertical = + vertical.and_then(|(pane, edge)| self.resolve_pane_resize_drag(pane, edge)); + if horizontal.is_some() || vertical.is_some() { + self.drag = Some(Drag::ResizeSplit { horizontal, vertical }); + } } Hit::TabScroll { pane, delta } => self.scroll_tabs(pane, delta), } @@ -11178,14 +12545,15 @@ impl App { // Begin a text selection; it becomes visible once the // mouse moves to a second cell. let offset = self.surface_scroll_offset(area.surface); - let cell = (x - content.x, offset + (y - content.y) as u64); + let source_x = area.content_source_x(); + let col = source_x.saturating_add(x - content.x); + let cell = (col, offset + (y - content.y) as u64); self.replace_selection(Some(Selection { surface: area.surface, anchor: cell, head: cell, })); - self.drag = - Some(Drag::Select { content, auto_scroll: None, col: x - content.x }); + self.drag = Some(Drag::Select { content, source_x, auto_scroll: None, col }); } } else if self.active_pane() != Some(area.pane) { self.focus_pane_after_input(area.pane); @@ -11226,14 +12594,19 @@ impl App { self.drag = Some(Drag::Workspace { workspace, target }); Ok(RenderAction::Draw) } - Some(Drag::Select { content, .. }) => { - let content = *content; + Some(Drag::Select { content, source_x, .. }) => { + let fallback = (*content, *source_x); + let (content, source_x) = self + .selection + .and_then(|selection| self.current_selection_geometry(selection.surface)) + .unwrap_or(fallback); let cx = x.clamp(content.x, content.x + content.width.saturating_sub(1)); let cy = y.clamp(content.y, content.y + content.height.saturating_sub(1)); + let col = source_x.saturating_add(cx - content.x); let offset = self.selection.map(|sel| self.surface_scroll_offset(sel.surface)).unwrap_or(0); if let Some(mut selection) = self.selection { - selection.head = (cx - content.x, offset + (cy - content.y) as u64); + selection.head = (col, offset + (cy - content.y) as u64); self.replace_selection(Some(selection)); } let auto_scroll = if y <= content.y { @@ -11243,11 +12616,12 @@ impl App { } else { None }; - self.drag = Some(Drag::Select { content, auto_scroll, col: cx - content.x }); + self.drag = Some(Drag::Select { content, source_x, auto_scroll, col }); Ok(RenderAction::Draw) } Some(Drag::Browser { surface, content, .. }) => { - let (surface, content) = (*surface, *content); + let surface = *surface; + let content = self.current_browser_content(surface).unwrap_or(*content); let cx = x.clamp(content.x, content.x + content.width.saturating_sub(1)); let cy = y.clamp(content.y, content.y + content.height.saturating_sub(1)); self.send_browser_mouse( @@ -11267,6 +12641,21 @@ impl App { self.drag_scrollbar(surface, track, anchor_y, anchor_offset, y); Ok(RenderAction::Draw) } + Some(Drag::HorizontalScrollbar { track, anchor_x, anchor_offset }) => { + let (track, anchor_x, anchor_offset) = (*track, *anchor_x, *anchor_offset); + if let Some((content_width, viewport_width, _)) = self.horizontal_scrollbar_state() + { + let target = horizontal_drag_offset( + content_width, + viewport_width, + track.width, + anchor_offset, + i128::from(x) - i128::from(anchor_x), + ); + self.set_viewport_target(target, false); + } + Ok(RenderAction::Draw) + } Some(Drag::WorkspaceScrollbar { track, total_rows, @@ -11300,11 +12689,11 @@ impl App { } Some(Drag::ResizeSplit { horizontal, vertical }) => { let (horizontal, vertical) = (*horizontal, *vertical); - if let Some((pane, edge)) = horizontal { - self.resize_split(pane, edge, x, y); + if let Some(target) = horizontal { + self.resize_drag_target(target, x, y); } - if let Some((pane, edge)) = vertical { - self.resize_split(pane, edge, x, y); + if let Some(target) = vertical { + self.resize_drag_target(target, x, y); } Ok(RenderAction::Draw) } @@ -11368,6 +12757,7 @@ impl App { } if let Some(Drag::Browser { surface, content, .. }) = self.drag { self.drag = None; + let content = self.current_browser_content(surface).unwrap_or(content); let cx = x.clamp(content.x, content.x + content.width.saturating_sub(1)); let cy = y.clamp(content.y, content.y + content.height.saturating_sub(1)); self.send_browser_mouse( @@ -11549,6 +12939,25 @@ impl App { fn resize_focused_split(&mut self, delta: f32) { let Some(pane) = self.active_pane() else { return }; let Some(screen) = self.tree.active_screen() else { return }; + if screen.zoomed_pane.is_none() + && !screen.viewport_splits.is_empty() + && let Some(owner) = screen.layout.viewport_column_owner(pane, &screen.viewport_splits) + { + let current = match owner { + ViewportColumn::Base => screen.viewport_base_width.unwrap_or(1.0), + ViewportColumn::Split(split) => { + let Some(width) = screen.viewport_splits.get(&split).copied() else { + return; + }; + width + } + }; + let width = (current + delta).clamp(MIN_VIEWPORT_PANE_WIDTH, MAX_VIEWPORT_PANE_WIDTH); + if (width - current).abs() >= f32::EPSILON && self.prepare_pty_input_before_mutation() { + self.session.set_viewport_pane_width(pane, width); + } + return; + } let Some(area) = self.pane_areas.iter().find(|area| area.pane == pane) else { return; }; @@ -11561,38 +12970,60 @@ impl App { let Some((edge, target)) = candidates .into_iter() .filter_map(|(split_edge, pane_edge)| { - exact_split_for_pane_edge( - &screen.layout, - self.content_area, - Some(screen.active_pane), - pane, - split_edge, - ) + if screen.viewport_splits.is_empty() { + exact_split_for_pane_edge( + &screen.layout, + self.content_area, + Some(screen.active_pane), + pane, + split_edge, + ) + .map(Into::into) + } else { + exact_split_for_pane_edge_with_viewport( + &screen.layout, + self.content_area, + Some(screen.active_pane), + pane, + split_edge, + screen.viewport_base_width.unwrap_or(1.0), + &screen.viewport_splits, + ) + } .map(|target| (pane_edge, target)) }) - .min_by_key(|(_, target)| target.area.width as u32 * target.area.height as u32) + .filter(|(_, target)| !screen.viewport_splits.contains_key(&target.split)) + .min_by_key(|(_, target)| { + u128::from(target.area.width) * u128::from(target.area.height) + }) else { return; }; + let pane_rect = self + .viewport_layout + .iter() + .find(|(candidate, _)| *candidate == pane) + .map_or_else(|| area.rect.into(), |(_, rect)| *rect); let (current, sign) = match edge { PaneEdge::Left => ( - (area.rect.x.saturating_sub(target.area.x)) as f32 + (pane_rect.x.saturating_sub(target.area.x)) as f32 / target.area.width.max(1) as f32, -1.0, ), PaneEdge::Right => ( - (area.rect.x + area.rect.width).saturating_sub(target.area.x) as f32 + (pane_rect.x + pane_rect.width).saturating_sub(target.area.x) as f32 / target.area.width.max(1) as f32, 1.0, ), PaneEdge::Top => ( - (area.rect.y.saturating_sub(target.area.y)) as f32 - / target.area.height.max(1) as f32, + f32::from(pane_rect.y.saturating_sub(target.area.y)) + / f32::from(target.area.height.max(1)), -1.0, ), PaneEdge::Bottom => ( - (area.rect.y + area.rect.height).saturating_sub(target.area.y) as f32 - / target.area.height.max(1) as f32, + f32::from( + pane_rect.y.saturating_add(pane_rect.height).saturating_sub(target.area.y), + ) / f32::from(target.area.height.max(1)), 1.0, ), }; @@ -11607,41 +13038,152 @@ impl App { } } - fn resize_split(&mut self, pane: PaneId, edge: PaneEdge, x: u16, y: u16) { - let Some(screen) = self.tree.active_screen() else { return }; + fn resolve_pane_resize_drag( + &self, + pane: PaneId, + edge: PaneEdge, + ) -> Option { + if self.content_area.width == 0 { + return None; + } + let screen = self.tree.active_screen()?; + if screen.zoomed_pane.is_none() + && !screen.viewport_splits.is_empty() + && matches!(edge, PaneEdge::Left | PaneEdge::Right) + { + let layout = layout_screen_with_viewport( + &screen.layout, + self.content_area, + Some(screen.active_pane), + screen.viewport_base_width.unwrap_or(1.0), + &screen.viewport_splits, + ); + let pane_rect = layout.rect_of(pane)?; + let pane_right = pane_rect.x.saturating_add(pane_rect.width); + let index = layout.columns.iter().position(|column| { + pane_rect.width > 0 + && pane_rect.x >= column.rect.x + && pane_right <= column.rect.x.saturating_add(column.rect.width) + })?; + let current = layout.columns[index]; + let target = match edge { + PaneEdge::Right + if pane_right == current.rect.x.saturating_add(current.rect.width) => + { + Some(current) + } + PaneEdge::Left if pane_rect.x == current.rect.x && index > 0 => { + Some(layout.columns[index - 1]) + } + _ => None, + }; + if let Some(column) = target { + return Some(PaneResizeDragTarget::ViewportColumn { + pane: column.representative, + edge, + column_x: column.rect.x, + viewport_x: self.content_area.x, + viewport_width: self.content_area.width, + viewport_offset: self.viewport_offset, + }); + } + } let split_edge = match edge { PaneEdge::Left => SplitEdge::Left, PaneEdge::Right => SplitEdge::Right, PaneEdge::Top => SplitEdge::Top, PaneEdge::Bottom => SplitEdge::Bottom, }; - let Some(target) = exact_split_for_pane_edge( - &screen.layout, - self.content_area, - Some(screen.active_pane), - pane, - split_edge, - ) else { - return; - }; - let (coord, start, extent) = match edge { - PaneEdge::Left => (x, target.area.x, target.area.width), - PaneEdge::Right => (x.saturating_add(1), target.area.x, target.area.width), - PaneEdge::Top => (y, target.area.y, target.area.height), - PaneEdge::Bottom => (y.saturating_add(1), target.area.y, target.area.height), + let target = if screen.viewport_splits.is_empty() { + exact_split_for_pane_edge( + &screen.layout, + self.content_area, + Some(screen.active_pane), + pane, + split_edge, + ) + .map(Into::into) + } else { + exact_split_for_pane_edge_with_viewport( + &screen.layout, + self.content_area, + Some(screen.active_pane), + pane, + split_edge, + screen.viewport_base_width.unwrap_or(1.0), + &screen.viewport_splits, + ) }; - if extent == 0 { - return; + let target = target?; + if screen.viewport_splits.contains_key(&target.split) { + return None; } - let requested = coord.saturating_sub(start) as f32 / extent as f32; - let ratio = clamp_split_ratio_for_tab_bars( - &screen.layout, - target.split, - target.area.height, - requested, - ); - if self.prepare_pty_input_before_mutation() { - self.session.set_split_ratio_deferred(target.split, ratio); + let minimum_ratio = + clamp_split_ratio_for_tab_bars(&screen.layout, target.split, target.area.height, 0.0); + let maximum_ratio = + clamp_split_ratio_for_tab_bars(&screen.layout, target.split, target.area.height, 1.0); + Some(PaneResizeDragTarget::Split { + split: target.split, + edge, + area: target.area, + minimum_ratio, + maximum_ratio, + viewport_x: self.content_area.x, + viewport_offset: self.viewport_offset, + }) + } + + fn resize_drag_target(&mut self, target: PaneResizeDragTarget, x: u16, y: u16) { + match target { + PaneResizeDragTarget::ViewportColumn { + pane, + edge, + column_x, + viewport_x, + viewport_width, + viewport_offset, + } => { + let virtual_x = u64::from(viewport_x) + .saturating_add(viewport_offset) + .saturating_add(u64::from(x.saturating_sub(viewport_x))); + let boundary = + if edge == PaneEdge::Right { virtual_x.saturating_add(1) } else { virtual_x }; + let cells = boundary.saturating_sub(column_x); + let width = (((cells as f64) / f64::from(viewport_width)) as f32) + .clamp(MIN_VIEWPORT_PANE_WIDTH, MAX_VIEWPORT_PANE_WIDTH); + if self.prepare_pty_input_before_mutation() { + self.session.set_viewport_pane_width_deferred(pane, width); + } + } + PaneResizeDragTarget::Split { + split, + edge, + area, + minimum_ratio, + maximum_ratio, + viewport_x, + viewport_offset, + } => { + let virtual_x = u64::from(viewport_x) + .saturating_add(viewport_offset) + .saturating_add(u64::from(x.saturating_sub(viewport_x))); + let (coord, start, extent) = match edge { + PaneEdge::Left => (virtual_x, area.x, area.width), + PaneEdge::Right => (virtual_x.saturating_add(1), area.x, area.width), + PaneEdge::Top => (u64::from(y), u64::from(area.y), u64::from(area.height)), + PaneEdge::Bottom => { + (u64::from(y.saturating_add(1)), u64::from(area.y), u64::from(area.height)) + } + }; + if extent == 0 { + return; + } + let requested = (coord.saturating_sub(start) as f64 / extent as f64) as f32; + let ratio = requested.clamp(minimum_ratio, maximum_ratio); + if self.prepare_pty_input_before_mutation() { + self.session.set_split_ratio_deferred(split, ratio); + } + } } } @@ -11920,7 +13462,7 @@ impl App { let Some(surface) = self.session.surface(surface_id) else { return Ok(RenderAction::None) }; if surface.kind() == SurfaceKind::Browser { if area.content.contains(x, y) { - let (px, py) = self.browser_point(area.content, x, y); + let (px, py) = self.browser_point(surface_id, area.content, x, y); let delta = if down { 3.0 } else { -3.0 } * f64::from(self.cell_pixels.1); let _ = self.browser_input.enqueue(BrowserInputEvent { surface_id, @@ -11931,8 +13473,9 @@ impl App { } return Ok(RenderAction::None); } - let canonical_content = self.canonical_pty_content(surface_id, area.content); - if !canonical_content.contains(x, y) { + let canonical_content = self.canonical_pty_content(surface_id, area.logical_content_rect()); + let (logical_x, logical_y) = area.logical_content_point(x, y); + if !canonical_content.contains(logical_x, logical_y) { return Ok(RenderAction::None); } if area.content.contains(x, y) @@ -11983,6 +13526,13 @@ impl App { if self.menu.is_some() || self.prompt.is_some() { return Ok(RenderAction::None); } + let over_scrollbar = matches!(self.hit_at(x, y), Some(Hit::HorizontalScrollbar { .. })); + let over_pane = self.pane_area_at(x, y).is_some(); + if self.horizontal_scrollbar_state().is_some() && (over_scrollbar || over_pane) { + let step = (self.content_area.width / 6).max(1) as i16; + self.scroll_horizontal_viewport(if right { step } else { -step }, true); + return Ok(RenderAction::Draw); + } let Some(area) = self.pane_area_at(x, y).copied() else { return Ok(RenderAction::None); }; @@ -11995,9 +13545,11 @@ impl App { if self.active_pane() != Some(area.pane) { self.focus_pane_after_input(area.pane); } - if self.surface_kind(area.surface) == Some(SurfaceKind::Pty) - && !self.canonical_pty_content(area.surface, area.content).contains(x, y) - { + if self.surface_kind(area.surface) == Some(SurfaceKind::Pty) && { + let content = self.canonical_pty_content(area.surface, area.logical_content_rect()); + let (x, y) = area.logical_content_point(x, y); + !content.contains(x, y) + } { return Ok(RenderAction::None); } if area.content.contains(x, y) @@ -12045,8 +13597,13 @@ impl App { .and_then(|tab| tab.browser_source) } - fn browser_point(&self, content: Rect, x: u16, y: u16) -> (f64, f64) { - let col = x.saturating_sub(content.x) as f64 + 0.5; + fn browser_point(&self, surface: SurfaceId, content: Rect, x: u16, y: u16) -> (f64, f64) { + let source_x = self + .pane_areas + .iter() + .find(|area| area.surface == surface) + .map_or(0, PaneArea::content_source_x); + let col = source_x.saturating_add(x.saturating_sub(content.x)) as f64 + 0.5; let row = y.saturating_sub(content.y) as f64 + 0.5; (col * f64::from(self.cell_pixels.0), row * f64::from(self.cell_pixels.1)) } @@ -12065,7 +13622,7 @@ impl App { return; } let Some(surface) = self.session.surface(surface_id) else { return }; - let (px, py) = self.browser_point(content, x, y); + let (px, py) = self.browser_point(surface_id, content, x, y); let _ = self.browser_input.enqueue(BrowserInputEvent { surface_id, surface, @@ -12174,6 +13731,7 @@ fn action_prepares_pty_release(action: Action) -> bool { | Action::RenameScreen | Action::RenameWorkspace | Action::NewWorkspace + | Action::NewPaneRight | Action::ScrollUp | Action::ScrollDown | Action::BrowserEditUrl @@ -12341,21 +13899,26 @@ mod tests { use super::{ App, AppEvent, BACKGROUND_REFRESH_RETRIES, ContextMenu, DeferredInput, Drag, FocusTarget, ForwardMuxOutcome, MachineActionWorker, MachineConnectRoute, MenuAction, MenuItem, - MuxTitleIngress, OrderedSession, OuterCursorSpec, PaneArea, PaneEdge, PaneFocusHistory, + MuxTitleIngress, OmnibarHit, OmnibarState, OrderedSession, OuterCursorSpec, PaneArea, + PaneAreaProjection, PaneEdge, PaneFocusHistory, PaneResizeDragTarget, PaneViewportClip, PendingSessionMutation, PendingSessionMutationState, PromptTarget, PtyFailureIngress, PtyMousePressResult, RailKind, RenderAction, Selection, SessionCompletion, SessionCompletionAction, SessionEventSender, ShortcutHelp, SidebarLayout, SidebarPluginSyncClaim, SidebarPluginSyncState, StdoutLock, SurfaceResizeDecision, - SurfaceResizeOwnership, TerminalInput, WorkspaceRailSelection, action_available_in_mode, - browser_content_size_for_rect, browser_hover_forward_allowed, canonical_terminal_content, - clamp_split_ratio_for_tab_bars, client_menu_item, disable_host_keyboard_protocol, - enable_host_keyboard_protocol, forward_mux_event, forward_mux_events, - keyboard_protocol_accepts, negotiate_host_keyboard_protocol_with, outer_cursor_escape, - outer_cursor_escape_if_changed, pane_context_menu_groups, pane_parts_for_rect, - prepare_ordered_session, preserve_client_view, rail_drag_width, - record_surface_resize_dispatch_result, should_claim_clear_history_shortcut, - sidebar_layout_for, sidebar_plugin_status_settles_passive_claim, start_ordered_session, - with_panic_stdout_lock, + SurfaceResizeOwnership, TerminalInput, TextInput, VIEWPORT_ANIMATION_DURATION, + ViewportMotion, ViewportPaneAreaProjection, WorkspaceRailSelection, + action_available_in_mode, browser_content_size_for_rect, browser_frame_source_crop, + browser_hover_forward_allowed, browser_source_crop, canonical_terminal_content, + catch_renderer_panic, clamp_split_ratio_for_tab_bars, client_menu_item, + clip_horizontal_rect, disable_host_keyboard_protocol, enable_host_keyboard_protocol, + forward_host_input, forward_mux_event, forward_mux_events, keyboard_protocol_accepts, + layout_undo_error_completion, negotiate_host_keyboard_protocol_with, outer_cursor_escape, + outer_cursor_escape_if_changed, pane_area_projection_work, pane_context_menu_groups, + pane_parts_for_rect, prepare_ordered_session, preserve_client_view, rail_drag_width, + rebuild_pane_areas, record_surface_resize_dispatch_result, reset_pane_area_projection_work, + should_claim_clear_history_shortcut, sidebar_layout_for, + sidebar_plugin_status_settles_passive_claim, start_ordered_session, + swept_viewport_size_leases, with_panic_stdout_lock, }; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::path::PathBuf; @@ -12365,8 +13928,9 @@ mod tests { use std::time::{Duration, Instant}; use cmux_tui_core::{ - BrowserStatus, Direction, Mux, MuxEvent, Node, Rect, SplitDir, SurfaceId, SurfaceKind, - SurfaceOptions, ZoomMode, layout_screen, server, + BrowserFrame, BrowserStatus, Direction, LayoutUndoError, Mux, MuxEvent, Node, Rect, + SplitDir, SurfaceId, SurfaceKind, SurfaceOptions, VirtualRect, ZoomMode, layout_screen, + server, }; use crossterm::event::{ EnhancedKeyEvent, Event, KeyCode, KeyEvent, KeyModifiers, KeyboardEnhancementFlags, @@ -12379,6 +13943,7 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::style::Modifier; + use unicode_width::UnicodeWidthStr; use crate::browser_input::{BrowserInputDispatcher, BrowserInputEvent, BrowserInputKind}; use crate::config::{ @@ -12405,6 +13970,27 @@ mod tests { ClientInfo, ClientSizeInfo, RemoteSession, Session, SidebarPluginSurface, SurfaceHandle, TreeView, }; + + #[test] + fn renderer_panics_become_frontend_errors() { + let error = catch_renderer_panic(|| -> () { panic!("invalid render cell") }).unwrap_err(); + assert_eq!(error.to_string(), "terminal renderer panicked: invalid render cell"); + } + + #[test] + fn host_input_failure_is_forwarded_to_the_event_loop() { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + forward_host_input( + || -> std::io::Result { + Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "revoked tty")) + }, + &tx, + ); + match rx.recv().unwrap() { + AppEvent::HostInputFailed(error) => assert_eq!(error, "revoked tty"), + _ => panic!("expected host input failure"), + } + } use crate::sidebar_files::FileBrowser; fn settled(outcome: super::SessionMutationOutcome) -> AppEvent { @@ -12588,10 +14174,10 @@ mod tests { assert_eq!(app.prompt.as_ref().unwrap().input.as_str(), text); app.prompt = None; - app.omnibar = Some(super::OmnibarState { + app.omnibar = Some(OmnibarState { pane: 1, surface: 1, - input: crate::ui::input::TextInput::new("old".to_string()), + input: TextInput::new("old".to_string()), select_all: true, }); @@ -12622,10 +14208,10 @@ mod tests { assert_eq!(prompt.input.as_str(), "word"); assert_eq!(prompt.input.cursor, 0); app.prompt = None; - app.omnibar = Some(super::OmnibarState { + app.omnibar = Some(OmnibarState { pane: 1, surface: 1, - input: crate::ui::input::TextInput::new("https://example.com".to_string()), + input: TextInput::new("https://example.com".to_string()), select_all: false, }); @@ -13029,6 +14615,7 @@ mod tests { omnibar: None, content: Rect { x: 20, y: 0, width: 80, height: 24 }, track: None, + viewport: None, }); app.open_context_menu(30, 10); @@ -13150,6 +14737,7 @@ mod tests { omnibar: None, content: Rect { x: 20, y: 25, width: 80, height: 10 }, track: None, + viewport: None, }); app.hits.clear(); app.open_context_menu(30, 26); @@ -13560,8 +15148,17 @@ mod tests { fn opening_shortcut_help_finishes_an_active_split_resize() { let mux = Mux::new("shortcut-help-split-release-test", SurfaceOptions::default()); let mut app = test_app(Session::Local(mux)); - app.drag = - Some(Drag::ResizeSplit { horizontal: Some((1, PaneEdge::Right)), vertical: None }); + app.drag = Some(Drag::ResizeSplit { + horizontal: Some(PaneResizeDragTarget::ViewportColumn { + pane: 1, + edge: PaneEdge::Right, + column_x: 0, + viewport_x: 0, + viewport_width: 1, + viewport_offset: 0, + }), + vertical: None, + }); app.run_action(Action::ShowShortcuts).unwrap(); @@ -13902,6 +15499,866 @@ mod tests { } } + #[test] + fn viewport_pane_overflows_the_existing_tiled_layout() { + let mux = Mux::new("viewport-pane-layout-test", SurfaceOptions::default()); + mux.new_workspace(None, Some((80, 24))).unwrap(); + let left = Session::Local(mux.clone()).tree().active_screen().unwrap().active_pane; + mux.split(left, SplitDir::Right, Some((40, 24))).unwrap(); + let right = Session::Local(mux.clone()).tree().active_screen().unwrap().active_pane; + + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.viewport_states.insert(u64::MAX, ViewportMotion::new(Instant::now())); + app.replace_tree(app.session.tree()); + assert!( + !app.viewport_states.contains_key(&u64::MAX), + "closed screens must not leave viewport animation state behind" + ); + app.sync_layout((80, 25)); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + assert!(app.tree.active_screen().unwrap().viewport_splits.is_empty()); + assert_eq!(app.horizontal_scrollbar_state(), None); + assert_eq!(app.pane_areas.len(), 2); + assert!(app.pane_areas.iter().all(|area| area.rect.width == 40)); + + mux.new_pane_right(right, 2.0 / 3.0, Some((51, 22))).unwrap(); + app.replace_tree(app.session.tree()); + app.config.viewport.animation = false; + app.sync_layout((80, 25)); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + + let screen = app.tree.active_screen().unwrap(); + let screen_id = screen.id; + let appended = screen.active_pane; + assert_eq!(screen.viewport_splits.len(), 1); + assert_eq!(app.horizontal_scrollbar_state(), Some((133, 80, 53))); + assert_eq!(app.pane_areas.len(), 2); + assert_eq!( + app.pane_areas.iter().find(|area| area.pane == right).unwrap().rect, + Rect { x: 0, y: 0, width: 27, height: 24 } + ); + assert_eq!( + app.pane_areas.iter().find(|area| area.pane == appended).unwrap().rect, + Rect { x: 27, y: 0, width: 53, height: 24 } + ); + + let mut terminal = Terminal::new(TestBackend::new(80, 25)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let track = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!(hit, super::Hit::HorizontalScrollbar { .. }).then_some(*rect) + }) + .expect("overflow must render a horizontal track"); + + let (content_width, viewport_width, initial_offset) = + app.horizontal_scrollbar_state().unwrap(); + let (thumb_x, _) = crate::ui::horizontal_thumb_geometry( + content_width, + viewport_width, + initial_offset, + track.width, + ); + app.handle_left_down(track.x + thumb_x, track.y, KeyModifiers::NONE).unwrap(); + assert_eq!( + app.viewport_states[&screen_id].offset(), + initial_offset, + "grabbing the thumb must not jump the viewport" + ); + app.handle_left_up(track.x + thumb_x, track.y).unwrap(); + + app.handle_left_down(track.x, track.y, KeyModifiers::NONE).unwrap(); + assert!(matches!(app.drag, Some(Drag::HorizontalScrollbar { .. }))); + app.handle_left_drag(track.x + track.width - 1, track.y).unwrap(); + app.handle_left_up(track.x + track.width - 1, track.y).unwrap(); + assert!(app.drag.is_none()); + assert_eq!(app.viewport_states[&screen_id].offset(), 53); + + assert!(app.set_viewport_target(0, false)); + app.sync_layout((80, 25)); + let mut baseline = Terminal::new(TestBackend::new(80, 25)).unwrap(); + baseline.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let expected = (4..12) + .map(|x| baseline.backend().buffer()[(x, 0)].symbol().to_string()) + .collect::>(); + assert!(expected.iter().any(|symbol| symbol != "─")); + + assert!(app.set_viewport_target(1, false)); + app.sync_layout((80, 25)); + let border_only = app.pane_areas.iter().find(|area| area.pane == appended).unwrap(); + assert_eq!(border_only.rect.width, 1); + assert_eq!( + border_only.content, + Rect { x: 79, y: 1, width: 0, height: 22 }, + "a border-only crop must retain the terminal content rows" + ); + + assert!(app.set_viewport_target(4, false)); + app.sync_layout((80, 25)); + let clipped = app.pane_areas.iter().find(|area| area.pane == left).unwrap(); + assert_eq!(clipped.viewport.unwrap().rect_source_x, 4); + let mut cropped = Terminal::new(TestBackend::new(80, 25)).unwrap(); + cropped.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let actual = (0..8) + .map(|x| cropped.backend().buffer()[(x, 0)].symbol().to_string()) + .collect::>(); + assert_eq!(actual, expected, "the tab bar must move through the viewport as one surface"); + + let surfaces = mux.with_state(|state| state.surfaces.keys().copied().collect::>()); + for surface in surfaces { + mux.close_surface(surface).unwrap(); + } + } + + #[test] + fn horizontal_status_bar_reserves_terminal_cells_for_wide_messages() { + let (mux, first) = test_mux("wide-status-message-test", None); + let first_pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + mux.new_pane_right(first_pane, cmux_tui_core::DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))) + .unwrap(); + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.config.viewport.animation = false; + app.replace_tree(app.session.tree()); + app.sync_layout((80, 25)); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + app.status_message = Some("復元失敗".to_string()); + + let mut terminal = Terminal::new(TestBackend::new(80, 25)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let buffer = terminal.backend().buffer(); + let expected = [(71, "復"), (73, "元"), (75, "失"), (77, "敗")]; + for (x, symbol) in expected { + assert_eq!(buffer[(x, 24)].symbol(), symbol); + } + let track = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!(hit, super::Hit::HorizontalScrollbar { .. }).then_some(*rect) + }) + .expect("wide viewport should render a horizontal scrollbar"); + assert!(track.x + track.width < 70, "track must end before the 10-cell status label"); + + app.status_message = Some("status failure details ".repeat(20)); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let rendered = buffer_text(terminal.backend().buffer()); + let status_row = rendered.lines().last().unwrap(); + assert!( + status_row.contains('…'), + "an exact-fit truncated status label must remain visible: {status_row:?}" + ); + + let surfaces = mux.with_state(|state| state.surfaces.keys().copied().collect::>()); + for surface in surfaces { + mux.close_surface(surface).unwrap(); + } + } + + #[test] + fn viewport_columns_resize_with_shortcuts_and_mouse_drag() { + let mux = Mux::new("viewport-pane-resize-test", SurfaceOptions::default()); + mux.new_workspace(None, Some((80, 24))).unwrap(); + let base = Session::Local(mux.clone()).tree().active_screen().unwrap().active_pane; + let appended_surface = mux + .new_pane_right(base, cmux_tui_core::DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))) + .unwrap(); + let appended = mux.with_state(|state| state.pane_of(appended_surface.id).unwrap()); + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.config.viewport.animation = false; + app.replace_tree(app.session.tree()); + app.sync_layout((80, 25)); + + app.resize_focused_split(-0.05); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + let screen = app.tree.active_screen().unwrap(); + let appended_split = + match screen.layout.viewport_column_owner(appended, &screen.viewport_splits).unwrap() { + cmux_tui_core::ViewportColumn::Split(split) => split, + cmux_tui_core::ViewportColumn::Base => { + panic!("appended pane must be its own column") + } + }; + assert!( + (screen.viewport_splits[&appended_split] + - (cmux_tui_core::DEFAULT_VIEWPORT_PANE_WIDTH - 0.05)) + .abs() + < f32::EPSILON + ); + + app.sync_layout((80, 25)); + let appended_area = *app.pane_areas.iter().find(|area| area.pane == appended).unwrap(); + let drag_x = appended_area.rect.x.saturating_sub(8).max(app.content_area.x); + let virtual_boundary = app + .viewport_offset + .saturating_add(u64::from(drag_x.saturating_sub(app.content_area.x))); + let expected_base_width = + (virtual_boundary as f64 / f64::from(app.content_area.width)) as f32; + let mut terminal = Terminal::new(TestBackend::new(80, 25)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let handle = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!( + hit, + super::Hit::PaneResize { + horizontal: Some((pane, PaneEdge::Left)), + vertical: None, + } if *pane == appended + ) + .then_some(*rect) + }) + .expect("the viewport divider must expose a drag handle"); + app.handle_left_down(handle.x, handle.y, KeyModifiers::NONE).unwrap(); + assert!(matches!(app.drag, Some(Drag::ResizeSplit { .. }))); + app.handle_left_drag(drag_x, handle.y).unwrap(); + app.handle_left_up(drag_x, handle.y).unwrap(); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + assert!( + (app.tree.active_screen().unwrap().viewport_base_width.unwrap() - expected_base_width) + .abs() + < 0.001 + ); + + let surfaces = mux.with_state(|state| state.surfaces.keys().copied().collect::>()); + for surface in surfaces { + mux.close_surface(surface).unwrap(); + } + } + + #[test] + fn viewport_resize_drag_keeps_its_mouse_down_coordinate_origin() { + let mux = Mux::new("viewport-resize-origin-test", SurfaceOptions::default()); + let first = mux.new_workspace(None, Some((80, 24))).unwrap(); + let base = mux.with_state(|state| state.pane_of(first.id).unwrap()); + mux.new_pane_right(base, cmux_tui_core::DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))) + .unwrap(); + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.config.viewport.animation = false; + app.replace_tree(app.session.tree()); + app.sync_layout((80, 25)); + app.viewport_offset = 40; + let target = PaneResizeDragTarget::ViewportColumn { + pane: base, + edge: PaneEdge::Right, + column_x: 0, + viewport_x: 0, + viewport_width: 80, + viewport_offset: 40, + }; + + app.resize_drag_target(target, 20, 5); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + let first_width = mux.with_state(|state| { + state.workspaces[state.active_workspace].screens[0].viewport_base_width.unwrap() + }); + + app.viewport_offset = 10; + app.resize_drag_target(target, 20, 5); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + let second_width = mux.with_state(|state| { + state.workspaces[state.active_workspace].screens[0].viewport_base_width.unwrap() + }); + assert!( + (second_width - first_width).abs() < f32::EPSILON, + "live viewport motion must not change a drag's pointer mapping" + ); + + let surfaces = mux.with_state(|state| state.surfaces.keys().copied().collect::>()); + for surface in surfaces { + mux.close_surface(surface).unwrap(); + } + } + + #[test] + fn layout_undo_action_confirms_before_closing_a_created_pane() { + let mux = Mux::new("layout-undo-action-test", SurfaceOptions::default()); + mux.new_workspace(None, Some((80, 24))).unwrap(); + let base = Session::Local(mux.clone()).tree().active_screen().unwrap().active_pane; + let right = mux + .new_pane_right(base, cmux_tui_core::DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))) + .unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let moved = mux.new_tab(Some(base), None, None).unwrap(); + assert!(mux.move_tab(moved.id, right_pane, 1)); + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.replace_tree(app.session.tree()); + app.sync_layout((80, 25)); + let closing_pane = app.tree.pane(right_pane).unwrap().short_id.clone(); + let closing_tabs = app + .tree + .pane(right_pane) + .unwrap() + .tabs + .iter() + .map(|tab| tab.short_id.clone()) + .collect::>(); + + app.run_action(Action::UndoLayout).unwrap(); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + assert!(matches!( + app.prompt.as_ref().map(|prompt| prompt.target), + Some(PromptTarget::ConfirmLayoutUndo { pane, .. }) if pane == right_pane + )); + let prompt = app.prompt.as_ref().unwrap(); + assert!(prompt.label.contains(&closing_pane)); + assert!(closing_tabs.iter().all(|tab| prompt.label.contains(tab))); + assert!(mux.surface(right.id).is_some()); + assert!(mux.surface(moved.id).is_some()); + + app.prompt.as_mut().unwrap().input.insert_str("confirm"); + app.commit_prompt(); + assert!(app.prompt.is_some()); + assert_eq!( + app.status_message.as_deref(), + Some(localization::catalog().sidebar.confirmation_mismatch) + ); + + app.prompt.as_mut().unwrap().input.clear(); + app.prompt.as_mut().unwrap().input.insert_str("CONFIRM"); + app.commit_prompt(); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + assert!(app.prompt.is_none()); + assert!(mux.surface(right.id).is_none()); + assert!(mux.surface(moved.id).is_none()); + assert_eq!(app.tree.active_screen().unwrap().layout.pane_ids_vec(), vec![base]); + + app.run_action(Action::UndoLayout).unwrap(); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + assert_eq!( + app.status_message.as_deref(), + Some(localization::catalog().sidebar.layout_nothing_to_undo) + ); + } + + #[test] + fn stale_layout_undo_confirmation_keeps_the_created_pane() { + let mux = Mux::new("stale-layout-undo-action-test", SurfaceOptions::default()); + mux.new_workspace(None, Some((80, 24))).unwrap(); + let base = Session::Local(mux.clone()).tree().active_screen().unwrap().active_pane; + let right = mux + .new_pane_right(base, cmux_tui_core::DEFAULT_VIEWPORT_PANE_WIDTH, Some((51, 22))) + .unwrap(); + let right_pane = mux.with_state(|state| state.pane_of(right.id).unwrap()); + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.replace_tree(app.session.tree()); + app.sync_layout((80, 25)); + + app.run_action(Action::UndoLayout).unwrap(); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + assert!(app.prompt.is_some()); + assert!(mux.set_viewport_pane_width(right_pane, 0.5)); + + app.prompt.as_mut().unwrap().input.insert_str("CONFIRM"); + app.commit_prompt(); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + + assert!(mux.surface(right.id).is_some()); + assert_eq!( + app.status_message.as_deref(), + Some(localization::catalog().sidebar.layout_undo_stale) + ); + + mux.close_pane(right_pane).unwrap(); + } + + #[test] + fn viewport_motion_eases_and_can_jump_without_animation() { + let now = Instant::now(); + let mut motion = ViewportMotion::new(now); + motion.retarget(60, true, now); + + assert_eq!(motion.offset(), 0); + assert!(motion.update(now + VIEWPORT_ANIMATION_DURATION / 2)); + assert!(motion.offset() > 0); + assert!(motion.offset() < 60); + assert!(motion.animating()); + + motion.update(now + VIEWPORT_ANIMATION_DURATION); + assert_eq!(motion.offset(), 60); + assert!(!motion.animating()); + + motion.retarget(10, false, now + VIEWPORT_ANIMATION_DURATION); + assert_eq!(motion.offset(), 10); + assert!(!motion.animating()); + } + + #[test] + fn viewport_animation_leases_every_column_in_its_swept_range() { + let pane = |id, surface| PaneView { + id, + short_id: format!("p{id}"), + name: None, + tabs: vec![TabView { + surface, + short_id: format!("t{surface}"), + name: None, + title: format!("pane {id}"), + kind: SurfaceKind::Pty, + browser_source: None, + browser_frames_stalled: false, + notification: None, + supports_clear_history_key_fallback: false, + }], + active_tab: 0, + focused_at: id, + }; + let screen = ScreenView { + id: 4, + short_id: "s".to_string(), + name: None, + layout: Node::Leaf(1), + active_pane: 3, + zoomed_pane: None, + viewport_base_width: Some(1.0), + viewport_splits: BTreeMap::new(), + panes: vec![pane(1, 11), pane(2, 12), pane(3, 13)], + }; + let layout = vec![ + (1, VirtualRect { x: 0, y: 0, width: 80, height: 24 }), + (2, VirtualRect { x: 80, y: 0, width: 80, height: 24 }), + (3, VirtualRect { x: 160, y: 0, width: 80, height: 24 }), + ]; + + let leases = swept_viewport_size_leases( + PaneAreaProjection { + screen: &screen, + layout: &layout, + stacked_headers: &HashSet::new(), + area: Rect { x: 0, y: 0, width: 80, height: 24 }, + scrollbar_position: ScrollbarPosition::Column, + surface_only: None, + viewport_offset: Some(0), + }, + 160, + ) + .expect("a short animation sweep should stay within the synchronization budget"); + + assert_eq!(leases.iter().map(|lease| lease.surface).collect::>(), vec![11, 12, 13]); + } + + #[test] + fn viewport_animation_rejects_an_unbounded_synchronization_sweep() { + let pane = |id, surface| PaneView { + id, + short_id: format!("p{id}"), + name: None, + tabs: vec![TabView { + surface, + short_id: format!("t{surface}"), + name: None, + title: format!("pane {id}"), + kind: SurfaceKind::Pty, + browser_source: None, + browser_frames_stalled: false, + notification: None, + supports_clear_history_key_fallback: false, + }], + active_tab: 0, + focused_at: id, + }; + let pane_count = 80; + let screen = ScreenView { + id: 4, + short_id: "s".to_string(), + name: None, + layout: Node::Leaf(1), + active_pane: pane_count, + zoomed_pane: None, + viewport_base_width: Some(1.0), + viewport_splits: BTreeMap::new(), + panes: (1..=pane_count).map(|id| pane(id, 100 + id)).collect(), + }; + let layout = (1..=pane_count) + .map(|id| (id, VirtualRect { x: (id - 1) * 80, y: 0, width: 80, height: 24 })) + .collect::>(); + + let leases = swept_viewport_size_leases( + PaneAreaProjection { + screen: &screen, + layout: &layout, + stacked_headers: &HashSet::new(), + area: Rect { x: 0, y: 0, width: 80, height: 24 }, + scrollbar_position: ScrollbarPosition::Column, + surface_only: None, + viewport_offset: Some(0), + }, + (pane_count - 1) * 80, + ); + + assert!( + leases.is_none(), + "a distant jump must not enqueue synchronization for every crossed pane" + ); + } + + #[test] + fn viewport_reclip_work_is_bounded_by_visible_panes() { + let pane_count = 128u64; + let screen = ScreenView { + id: 4, + short_id: "s".to_string(), + name: None, + layout: Node::Leaf(1), + active_pane: 1, + zoomed_pane: None, + viewport_base_width: Some(1.0), + viewport_splits: BTreeMap::new(), + panes: (1..=pane_count) + .map(|id| PaneView { + id, + short_id: format!("p{id}"), + name: None, + tabs: vec![TabView { + surface: 100 + id, + short_id: format!("t{id}"), + name: None, + title: format!("pane {id}"), + kind: SurfaceKind::Pty, + browser_source: None, + browser_frames_stalled: false, + supports_clear_history_key_fallback: false, + notification: None, + }], + active_tab: 0, + focused_at: id, + }) + .collect(), + }; + let layout = (1..=pane_count) + .map(|id| (id, VirtualRect { x: (id - 1) * 80, y: 0, width: 80, height: 24 })) + .collect::>(); + let mux = Mux::new("viewport-reclip-work-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.content_area = Rect { x: 0, y: 0, width: 80, height: 24 }; + app.viewport_layout = layout; + app.viewport_virtual_width = pane_count * 80; + app.tree = TreeView { + workspaces: vec![WorkspaceView { + id: 3, + key: "workspace".to_string(), + short_id: "w".to_string(), + name: "workspace".to_string(), + screens: vec![screen], + active_screen: 0, + }], + workspace_revision: 1, + pane_revision: Some(1), + active_workspace: 0, + }; + + app.reclip_viewport_panes(); + app.viewport_offset = (pane_count - 1) * 80; + reset_pane_area_projection_work(); + app.reclip_viewport_panes(); + + assert_eq!(app.pane_areas.len(), 1); + assert!( + pane_area_projection_work() <= 2, + "one animation frame performed {} projection operations for one visible pane", + pane_area_projection_work() + ); + } + + #[test] + fn viewport_projection_cache_matches_full_projection_for_overlapping_ranges() { + let pane = |id| PaneView { + id, + short_id: format!("p{id}"), + name: None, + tabs: vec![TabView { + surface: 100 + id, + short_id: format!("t{id}"), + name: None, + title: format!("pane {id}"), + kind: SurfaceKind::Pty, + browser_source: None, + browser_frames_stalled: false, + supports_clear_history_key_fallback: false, + notification: None, + }], + active_tab: 0, + focused_at: id, + }; + let screen = ScreenView { + id: 4, + short_id: "s".to_string(), + name: None, + layout: Node::Leaf(1), + active_pane: 4, + zoomed_pane: None, + viewport_base_width: Some(1.0), + viewport_splits: BTreeMap::new(), + panes: (1..=4).map(pane).collect(), + }; + // A down split can return to an earlier x after visiting the upper + // branch, so the source traversal is intentionally not x-sorted. + let layout = vec![ + (1, VirtualRect { x: 0, y: 0, width: 80, height: 12 }), + (2, VirtualRect { x: 40, y: 12, width: 40, height: 12 }), + (3, VirtualRect { x: 0, y: 12, width: 40, height: 12 }), + (4, VirtualRect { x: 80, y: 0, width: 53, height: 24 }), + ]; + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let stacked_headers = HashSet::new(); + let mut cache = ViewportPaneAreaProjection::default(); + cache.rebuild(PaneAreaProjection { + screen: &screen, + layout: &layout, + stacked_headers: &stacked_headers, + area, + scrollbar_position: ScrollbarPosition::Column, + surface_only: None, + viewport_offset: Some(0), + }); + let snapshot = |areas: &[PaneArea]| { + let mut snapshot = areas + .iter() + .map(|area| { + ( + area.pane, + area.surface, + area.rect, + area.bar, + area.omnibar, + area.content, + area.track, + area.viewport.map(|clip| { + ( + clip.rect_source_x, + clip.full_rect_width, + clip.omnibar_source_x, + clip.full_omnibar_width, + clip.content_source_x, + clip.full_content_width, + ) + }), + ) + }) + .collect::>(); + snapshot.sort_unstable_by_key(|area| area.0); + snapshot + }; + + for offset in [0, 39, 53] { + let mut expected = Vec::new(); + rebuild_pane_areas( + &mut expected, + PaneAreaProjection { + screen: &screen, + layout: &layout, + stacked_headers: &stacked_headers, + area, + scrollbar_position: ScrollbarPosition::Column, + surface_only: None, + viewport_offset: Some(offset), + }, + ); + let mut actual = Vec::new(); + cache.project_into(&mut actual, area, offset); + assert_eq!(snapshot(&actual), snapshot(&expected), "projection mismatch at {offset}"); + } + } + + #[test] + fn viewport_animation_tick_reclips_without_authoritative_layout_draw() { + let mux = Mux::new("viewport-animation-paint-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.sidebar_visible = false; + app.content_area = Rect { x: 0, y: 0, width: 80, height: 24 }; + app.viewport_layout = vec![ + (1, VirtualRect { x: 0, y: 0, width: 80, height: 24 }), + (2, VirtualRect { x: 80, y: 0, width: 53, height: 24 }), + ]; + app.viewport_virtual_width = 133; + app.tree = TreeView { + workspaces: vec![WorkspaceView { + id: 3, + key: "workspace".to_string(), + short_id: "w".to_string(), + name: "workspace".to_string(), + screens: vec![ScreenView { + id: 4, + short_id: "s".to_string(), + name: None, + layout: Node::Leaf(1), + active_pane: 2, + zoomed_pane: None, + viewport_base_width: Some(1.0), + viewport_splits: BTreeMap::new(), + panes: vec![ + PaneView { + id: 1, + short_id: "p1".to_string(), + name: None, + tabs: vec![TabView { + surface: 11, + short_id: "t1".to_string(), + name: None, + title: "left".to_string(), + kind: SurfaceKind::Pty, + browser_source: None, + browser_frames_stalled: false, + notification: None, + supports_clear_history_key_fallback: false, + }], + active_tab: 0, + focused_at: 0, + }, + PaneView { + id: 2, + short_id: "p2".to_string(), + name: None, + tabs: vec![TabView { + surface: 12, + short_id: "t2".to_string(), + name: None, + title: "right".to_string(), + kind: SurfaceKind::Pty, + browser_source: None, + browser_frames_stalled: false, + notification: None, + supports_clear_history_key_fallback: false, + }], + active_tab: 0, + focused_at: 1, + }, + ], + }], + active_screen: 0, + }], + workspace_revision: 1, + pane_revision: Some(1), + active_workspace: 0, + }; + let started_at = Instant::now(); + let mut motion = ViewportMotion::new(started_at); + motion.retarget(53, true, started_at); + app.viewport_states.insert(4, motion); + app.reclip_viewport_panes(); + + assert!(app.viewport_animation_active()); + assert_eq!(app.viewport_offset, 0); + assert_eq!(app.pane_areas.iter().find(|area| area.pane == 1).unwrap().rect.width, 80); + app.tree.workspace_revision = u64::MAX; + + assert_eq!( + app.advance_viewport_animation(started_at + VIEWPORT_ANIMATION_DURATION / 2), + RenderAction::Paint + ); + assert!(app.viewport_offset > 0); + assert!( + app.pane_areas.iter().find(|area| area.pane == 1).unwrap().rect.width < 80, + "paint-only animation ticks must reclip cached pane geometry" + ); + assert_eq!( + app.tree.workspace_revision, + u64::MAX, + "animation paint must preserve the cached authoritative tree" + ); + assert_eq!( + app.advance_viewport_animation(started_at + VIEWPORT_ANIMATION_DURATION), + RenderAction::Draw, + "the settling tick must release swept-range leases through one authoritative draw" + ); + } + + #[test] + fn viewport_geometry_changes_reveal_the_active_pane_without_canceling_manual_scroll() { + let mux = Mux::new("viewport-geometry-reveal-test", SurfaceOptions::default()); + mux.new_workspace(None, Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.config.viewport.animation = false; + app.replace_tree(app.session.tree()); + let screen = app.tree.active_screen().unwrap(); + let screen_id = screen.id; + let active_pane = screen.active_pane; + let now = Instant::now(); + let original_area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let original_active = Rect { x: 80, y: 0, width: 53, height: 24 }; + + assert_eq!( + app.sync_viewport_motion( + screen_id, + active_pane, + Some(original_active.into()), + original_area, + 133, + now, + ), + 53 + ); + assert!(app.set_viewport_target(0, false)); + assert_eq!( + app.sync_viewport_motion( + screen_id, + active_pane, + Some(original_active.into()), + original_area, + 133, + now, + ), + 0, + "unchanged geometry must preserve an intentional manual scroll" + ); + assert_eq!( + app.sync_viewport_motion( + screen_id, + active_pane, + Some(Rect { y: 1, height: 20, ..original_active }.into()), + Rect { y: 1, height: 20, ..original_area }, + 133, + now, + ), + 0, + "vertical-only geometry changes must preserve manual horizontal scroll" + ); + + assert_eq!( + app.sync_viewport_motion( + screen_id, + active_pane, + Some(Rect { x: 120, y: 0, width: 80, height: 24 }.into()), + Rect { x: 0, y: 0, width: 120, height: 24 }, + 200, + now, + ), + 80, + "host resize must reveal the unchanged active pane in its new geometry" + ); + } + #[test] fn remote_screen_switch_records_the_new_active_pane() { let mux = Mux::new("remote-screen-focus-memory-test", SurfaceOptions::default()); @@ -14660,7 +17117,10 @@ mod tests { } for y in [0, 17] { - app.resize_split(nested_bottom, PaneEdge::Bottom, 0, y); + let target = app + .resolve_pane_resize_drag(nested_bottom, PaneEdge::Bottom) + .expect("nested split divider must remain draggable"); + app.resize_drag_target(target, 0, y); app.session.settle_split_ratio(); while app.session.has_pending_mutations() { app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); @@ -15089,6 +17549,138 @@ mod tests { assert_eq!(track, Some(Rect { x: 88, y: 5, width: 1, height: 22 })); } + #[test] + fn clipped_browser_omnibar_keeps_logical_hit_coordinates() { + let mux = Mux::new("clipped-browser-omnibar-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(browser_completion_tree(41, 41)); + app.pane_areas.push(PaneArea { + pane: 2, + surface: 41, + rect: Rect { x: 20, y: 4, width: 10, height: 8 }, + bar: Some(Rect { x: 20, y: 4, width: 10, height: 1 }), + omnibar: Some(Rect { x: 20, y: 5, width: 8, height: 1 }), + content: Rect { x: 20, y: 6, width: 8, height: 5 }, + track: None, + viewport: Some(PaneViewportClip { + rect_source_x: 4, + full_rect_width: 40, + omnibar_source_x: 4, + full_omnibar_width: 38, + content_source_x: 4, + full_content_width: 38, + }), + }); + + assert_eq!(app.omnibar_hit_at(21, 5), Some((2, OmnibarHit::Reload))); + assert_eq!(app.omnibar_hit_at(23, 5), Some((2, OmnibarHit::Edit))); + assert_eq!(app.omnibar_hit_at(20, 5), None); + } + + #[test] + fn clipped_browser_omnibar_keeps_editing_and_clicks_visible() { + let mux = Mux::new("clipped-browser-omnibar-edit-test", SurfaceOptions::default()); + let surface = mux.new_browser_tab("about:blank".to_string(), None, Some((38, 5))).unwrap(); + let mut app = test_app(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); + let pane = app.tree.active_screen().expect("active screen").active_pane; + let area = PaneArea { + pane, + surface: surface.id, + rect: Rect { x: 20, y: 4, width: 10, height: 8 }, + bar: Some(Rect { x: 20, y: 4, width: 10, height: 1 }), + omnibar: Some(Rect { x: 20, y: 5, width: 8, height: 1 }), + content: Rect { x: 20, y: 6, width: 8, height: 5 }, + track: None, + viewport: Some(PaneViewportClip { + rect_source_x: 4, + full_rect_width: 40, + omnibar_source_x: 4, + full_omnibar_width: 38, + content_source_x: 4, + full_content_width: 38, + }), + }; + app.pane_areas.push(area); + app.omnibar = Some(OmnibarState { + pane, + surface: surface.id, + input: TextInput::new("x".to_string()), + select_all: false, + }); + + let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap(); + let mut cursor = None; + terminal + .draw(|frame| { + cursor = crate::ui::omnibar::draw(&mut app, frame, &area); + }) + .unwrap(); + assert_eq!(terminal.backend().buffer()[(20, 5)].symbol(), "x"); + assert_eq!(cursor, Some((21, 5))); + + app.omnibar.as_mut().unwrap().input = TextInput::new("0123456789".to_string()); + app.handle_left_down(22, 5, KeyModifiers::NONE).unwrap(); + assert_eq!( + app.omnibar.as_mut().unwrap().input.visible_text_and_cursor(38).1, + 2, + "a click must use the visible editor column, not the cropped logical column" + ); + mux.shutdown(); + } + + #[test] + fn browser_omnibar_places_stall_suffix_after_emoji_display_cells() { + let mux = Mux::new("emoji-browser-omnibar-test", SurfaceOptions::default()); + let surface = mux.new_browser_tab("emoji:👩‍💻".to_string(), None, Some((48, 8))).unwrap(); + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.replace_tree(app.session.tree()); + app.sync_layout((50, 12)); + let pane = app.tree.active_screen().unwrap().active_pane; + let tab = app + .tree + .active_workspace_mut_screen() + .unwrap() + .panes + .iter_mut() + .find(|candidate| candidate.id == pane) + .unwrap() + .tabs + .first_mut() + .unwrap(); + tab.browser_frames_stalled = true; + let area = *app.pane_areas.iter().find(|area| area.pane == pane).unwrap(); + let omnibar = area.omnibar.unwrap(); + let handle = app.session.surface(surface.id).unwrap(); + let mut label = handle.browser_url().unwrap(); + if matches!(handle.browser_status(), Some(BrowserStatus::Starting)) + || (matches!(handle.browser_status(), Some(BrowserStatus::Live)) + && !handle.has_browser_frame()) + { + label.push('…'); + } + let suffix = " ⏸ chrome tab hidden"; + let max = omnibar.width.saturating_sub(9) as usize; + let label_max = max - suffix.width(); + let text = crate::ui::truncate(&label, label_max); + let expected_icon_x = omnibar.x + 9 + text.width() as u16 + 1; + + let mut terminal = Terminal::new(TestBackend::new(50, 12)).unwrap(); + terminal + .draw(|frame| { + crate::ui::omnibar::draw(&mut app, frame, &area); + }) + .unwrap(); + assert_eq!( + terminal.backend().buffer()[(expected_icon_x, omnibar.y)].symbol(), + "⏸", + "the stall suffix must start after the label's display cells" + ); + + mux.shutdown(); + } + #[test] fn browser_omnibar_degrades_gracefully_with_one_content_row() { let rect = Rect { x: 0, y: 0, width: 20, height: 3 }; @@ -15170,6 +17762,7 @@ mod tests { omnibar: None, content, track: None, + viewport: None, }); app.rendered_terminal_bounds.insert(surface.id, content); @@ -15196,13 +17789,17 @@ mod tests { app.handle_mouse(event(MouseEventKind::ScrollDown, KeyModifiers::NONE)).unwrap(); assert_eq!(app.encode_buf, b"\x1b[<65;5;3M"); + app.content_area = content; + app.viewport_virtual_width = u64::from(content.width) * 2; + app.encode_buf.clear(); app.handle_mouse(event(MouseEventKind::ScrollLeft, KeyModifiers::NONE)).unwrap(); - assert_eq!(app.encode_buf, b"\x1b[<66;5;3M"); + assert!(app.encode_buf.is_empty()); app.handle_mouse(event(MouseEventKind::ScrollRight, KeyModifiers::NONE)).unwrap(); - assert_eq!(app.encode_buf, b"\x1b[<67;5;3M"); + assert!(app.encode_buf.is_empty()); + let screen = app.active_screen_id().unwrap(); + assert!(app.viewport_states[&screen].target > 0.0); - app.encode_buf.clear(); app.open_context_menu(content.x + 4, content.y + 2); app.handle_mouse(event(MouseEventKind::ScrollDown, KeyModifiers::NONE)).unwrap(); assert!(app.encode_buf.is_empty()); @@ -15294,6 +17891,159 @@ mod tests { mux.close_surface(surface.id).unwrap(); } + #[test] + fn layout_undo_expected_failures_are_typed_and_localized() { + assert!(matches!( + layout_undo_error_completion(&anyhow::Error::new(LayoutUndoError::Stale( + "layout changed since the last undoable action".to_string() + ))), + Some(SessionCompletionAction::LayoutUndoStale) + )); + assert!(matches!( + layout_undo_error_completion(&anyhow::Error::new(LayoutUndoError::Unavailable)), + Some(SessionCompletionAction::LayoutUndoUnavailable) + )); + } + + #[test] + fn browser_crop_scales_to_downsampled_frame_width() { + assert_eq!(browser_source_crop(50, 20, 40, 100), Some((10, 20))); + assert_eq!(browser_source_crop(101, 20, 40, 100), Some((20, 41))); + assert_eq!(browser_source_crop(50, 90, 20, 100), Some((45, 5))); + assert_eq!(browser_source_crop(0, 20, 40, 100), None); + } + + #[test] + fn browser_crop_uses_the_encoded_frame_width_instead_of_css_width() { + let frame = BrowserFrame { + session_id: "test".to_string(), + data_b64: "frame".to_string(), + css_width: 200, + css_height: 100, + image_width: 50, + image_height: 25, + seq: 1, + }; + + assert_eq!(browser_frame_source_crop(&frame, 20, 40, 100), Some((10, 20))); + } + + #[test] + fn focus_loss_settles_an_active_split_resize_transaction() { + let mux = Mux::new("focus-lost-split-resize-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let transaction = app.session.layout_resize_transaction.load(Ordering::Acquire); + app.drag = Some(Drag::ResizeSplit { + horizontal: Some(PaneResizeDragTarget::ViewportColumn { + pane: 1, + edge: PaneEdge::Right, + column_x: 0, + viewport_x: 0, + viewport_width: 1, + viewport_offset: 0, + }), + vertical: None, + }); + + app.handle(AppEvent::Input(Event::FocusLost)).unwrap(); + + assert!(app.drag.is_none()); + assert_ne!(app.session.layout_resize_transaction.load(Ordering::Acquire), transaction); + } + + #[test] + fn a_new_left_press_settles_an_abandoned_split_resize_transaction() { + let mux = Mux::new("new-press-split-resize-test", SurfaceOptions::default()); + let mut app = test_app(Session::Local(mux)); + let transaction = app.session.layout_resize_transaction.load(Ordering::Acquire); + app.drag = Some(Drag::ResizeSplit { + horizontal: Some(PaneResizeDragTarget::ViewportColumn { + pane: 1, + edge: PaneEdge::Right, + column_x: 0, + viewport_x: 0, + viewport_width: 1, + viewport_offset: 0, + }), + vertical: None, + }); + + app.handle_left_down(0, 0, KeyModifiers::NONE).unwrap(); + + assert_ne!(app.session.layout_resize_transaction.load(Ordering::Acquire), transaction); + } + + #[test] + fn horizontal_clip_projects_virtual_coordinates_after_u16_extent() { + assert_eq!( + clip_horizontal_rect( + VirtualRect { x: 131_070, y: 3, width: 65_535, height: 20 }, + 131_100, + 80, + 5, + ), + Some((Rect { x: 5, y: 3, width: 80, height: 20 }, 30)) + ); + } + + #[test] + fn clipped_terminal_input_uses_the_logical_source_column() { + let mux = Mux::new( + "clipped-terminal-input-test", + SurfaceOptions { + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]), + ..Default::default() + }, + ); + let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + surface.with_terminal(|terminal| terminal.vt_write(b"\x1b[?1002h\x1b[?1006h")); + let mut app = test_app(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); + let pane = app.tree.active_screen().unwrap().active_pane; + let content = Rect { x: 2, y: 3, width: 5, height: 8 }; + app.pane_areas.push(PaneArea { + pane, + surface: surface.id, + rect: Rect { x: 1, y: 2, width: 8, height: 10 }, + bar: Some(Rect { x: 1, y: 2, width: 8, height: 1 }), + omnibar: None, + content, + track: None, + viewport: Some(PaneViewportClip { + rect_source_x: 10, + full_rect_width: 23, + omnibar_source_x: 0, + full_omnibar_width: 0, + content_source_x: 10, + full_content_width: 20, + }), + }); + app.rendered_terminal_bounds.insert(surface.id, content); + app.rendered_terminal_sizes.insert(surface.id, (20, 8)); + let event = |kind, modifiers| MouseEvent { + kind, + column: content.x + 2, + row: content.y + 2, + modifiers, + }; + + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<0;13;3M"); + app.handle_mouse(event(MouseEventKind::Up(MouseButton::Left), KeyModifiers::NONE)).unwrap(); + assert_eq!(app.encode_buf, b"\x1b[<0;13;3m"); + + app.handle_mouse(event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::SHIFT)) + .unwrap(); + assert_eq!(app.selection.map(|selection| selection.anchor.0), Some(12)); + + mux.close_surface(surface.id).unwrap(); + } + #[test] fn pty_mouse_uses_the_canonical_rendered_grid_during_resize_margins() { let mux = Mux::new( @@ -15322,6 +18072,7 @@ mod tests { omnibar: None, content, track: None, + viewport: None, }); // The pane has already grown, but the only frame visible to the user // is still 12x5. The remaining cells are renderer-owned margins. @@ -15552,6 +18303,7 @@ mod tests { omnibar: None, content, track: None, + viewport: None, }); app.rendered_terminal_bounds.insert(surface.id, content); @@ -15602,6 +18354,7 @@ mod tests { omnibar: None, content, track: None, + viewport: None, }); app.rendered_terminal_bounds.insert(surface.id, content); app.focus = FocusTarget::WorkspaceRail; @@ -16001,6 +18754,7 @@ mod tests { omnibar: None, content, track: None, + viewport: None, }); let inputs = [ @@ -16295,8 +19049,16 @@ mod tests { app.sidebar_visible = false; app.sidebar_width = 0; app.content_area = rect; - app.pane_areas = - vec![PaneArea { pane, surface: second.id, rect, bar, omnibar, content, track }]; + app.pane_areas = vec![PaneArea { + pane, + surface: second.id, + rect, + bar, + omnibar, + content, + track, + viewport: None, + }]; let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); let first_tab = app @@ -16334,6 +19096,36 @@ mod tests { mux.close_workspace(workspace); } + #[test] + fn emoji_tab_name_fills_its_display_cell_hit_rect() { + let mux = Mux::new("emoji-tab-width-test", SurfaceOptions::default()); + let first = mux.new_browser_tab("about:blank".to_string(), None, Some((38, 7))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(first.id).unwrap()); + assert!(mux.rename_surface(first.id, "👩‍💻".to_string())); + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.replace_tree(app.session.tree()); + app.sync_layout((40, 10)); + + let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let tab = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!(hit, super::Hit::Tab { pane: hit_pane, index: 0 } if *hit_pane == pane) + .then_some(*rect) + }) + .expect("emoji tab hit"); + assert_eq!( + terminal.backend().buffer()[(tab.x + tab.width - 1, tab.y)].symbol(), + " ", + "every visible cell in the tab hit rect must belong to the label" + ); + + mux.shutdown(); + } + #[test] fn attached_workspace_release_without_press_is_a_no_op() { let mux = Mux::new( @@ -17015,6 +19807,73 @@ mod tests { assert!(!app.pending_size_releases.contains(&7)); } + #[test] + fn reverse_viewport_sweep_cancels_a_queued_size_release_and_reasserts() { + let mux = Mux::new("reverse-animation-size-release-test", SurfaceOptions::default()); + let first = mux.new_workspace(None, Some((78, 22))).unwrap(); + let base = mux.with_state(|state| state.pane_of(first.id).unwrap()); + let right = mux.new_pane_right(base, 1.0, Some((78, 22))).unwrap(); + let (mut app, events) = test_app_with_events(Session::Local(mux.clone())); + app.sidebar_visible = false; + app.config.viewport.animation = false; + app.replace_tree(app.session.tree()); + app.sync_layout((80, 25)); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + assert_eq!(app.viewport_offset, 80); + + mux.resize_surface_for_client(first.id, 0, 78, 22).unwrap(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + app.session.operations.enqueue_session_mutation( + "block queued size release", + false, + move || { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + }, + ); + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + app.visible_size_surfaces.insert(first.id); + let surface = app.session.surface(first.id).unwrap(); + let prior_claim = match app.session.surface_resize_decision(first.id, (78, 22), true) { + SurfaceResizeDecision::NeedsQueue(claim) => claim, + _ => panic!("the queued resize must claim the surface"), + }; + assert!(app.session.resize_surface(first.id, surface, 78, 22, false, prior_claim)); + let prior_claim_token = + app.session.surface_resize_claims.lock().unwrap().get(&first.id).unwrap().token; + assert!(app.session.release_surface_size(first.id)); + app.pending_size_releases.insert(first.id); + assert!(mux.focus_pane(base)); + app.config.viewport.animation = true; + app.sync_layout((80, 25)); + + let reassert_claim_token = + app.session.surface_resize_claims.lock().unwrap().get(&first.id).unwrap().token; + let release_canceled = !app.pending_size_releases.contains(&first.id); + release_tx.send(()).unwrap(); + while app.session.has_pending_mutations() { + app.handle(events.recv_timeout(Duration::from_secs(1)).unwrap()).unwrap(); + } + + assert!(release_canceled, "a visible surface must no longer be pending release"); + assert_ne!( + reassert_claim_token, prior_claim_token, + "the reverse sweep must supersede an older resize claim" + ); + assert!( + app.session.has_surface_size_report(first.id), + "the stale release must not drop the visible surface's sizing lease" + ); + + mux.close_surface(first.id).unwrap(); + mux.close_surface(right.id).unwrap(); + } + #[test] fn failed_surface_sync_is_bounded_until_lifecycle_recovery() { let mux = Mux::new("surface-sync-failure-test", SurfaceOptions::default()); @@ -18111,8 +20970,17 @@ mod tests { Ok(()) }); started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); - app.drag = - Some(Drag::ResizeSplit { horizontal: Some((1, PaneEdge::Right)), vertical: None }); + app.drag = Some(Drag::ResizeSplit { + horizontal: Some(PaneResizeDragTarget::ViewportColumn { + pane: 1, + edge: PaneEdge::Right, + column_x: 0, + viewport_x: 0, + viewport_width: 1, + viewport_offset: 0, + }), + vertical: None, + }); app.handle(AppEvent::Input(Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(MouseButton::Left), @@ -18273,6 +21141,48 @@ mod tests { assert!(app.drag.is_none()); } + #[test] + fn browser_drag_retargets_to_the_animated_pane_geometry() { + let mux = Mux::new("browser-live-drag-geometry-test", SurfaceOptions::default()); + let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + let mut app = test_app(Session::Local(mux.clone())); + let (dispatcher, received) = BrowserInputDispatcher::blocked(1); + app.browser_input = dispatcher; + let current = Rect { x: 20, y: 4, width: 10, height: 5 }; + app.pane_areas = vec![PaneArea { + pane, + surface: surface.id, + rect: current, + bar: None, + omnibar: None, + content: current, + track: None, + viewport: None, + }]; + app.drag = Some(Drag::Browser { + surface: surface.id, + content: Rect { x: 2, y: 3, width: 10, height: 5 }, + position: (5, 4), + }); + + app.handle_left_drag(23, 6).unwrap(); + + assert!(matches!( + app.drag, + Some(Drag::Browser { + surface: id, + content, + position: (23, 6), + }) if id == surface.id && content == current + )); + assert!(matches!( + received.recv_timeout(Duration::from_secs(1)).map(|event| event.kind), + Some(BrowserInputKind::Mouse { event_type: "mouseMoved", .. }) + )); + mux.close_surface(surface.id).unwrap(); + } + #[test] fn selection_drag_and_release_bypass_a_pending_focus_mutation() { let mux = Mux::new("selection-release-barrier-test", SurfaceOptions::default()); @@ -18281,7 +21191,7 @@ mod tests { app.session.pending_mutations.store(1, Ordering::Release); let content = Rect { x: 2, y: 3, width: 20, height: 8 }; app.selection = Some(Selection { surface: surface.id, anchor: (1, 1), head: (1, 1) }); - app.drag = Some(Drag::Select { content, auto_scroll: None, col: 1 }); + app.drag = Some(Drag::Select { content, source_x: 0, auto_scroll: None, col: 1 }); app.handle(AppEvent::Input(Event::Mouse(MouseEvent { kind: MouseEventKind::Drag(MouseButton::Left), @@ -18304,6 +21214,54 @@ mod tests { assert!(app.drag.is_none()); } + #[test] + fn selection_drag_retargets_to_the_animated_pane_geometry() { + let mux = Mux::new("selection-live-drag-geometry-test", SurfaceOptions::default()); + let surface = mux.new_workspace(None, Some((20, 8))).unwrap(); + let pane = mux.with_state(|state| state.pane_of(surface.id).unwrap()); + let mut app = test_app(Session::Local(mux.clone())); + let current = Rect { x: 20, y: 4, width: 10, height: 5 }; + app.pane_areas = vec![PaneArea { + pane, + surface: surface.id, + rect: current, + bar: None, + omnibar: None, + content: current, + track: None, + viewport: Some(PaneViewportClip { + rect_source_x: 7, + full_rect_width: 20, + omnibar_source_x: 0, + full_omnibar_width: 0, + content_source_x: 7, + full_content_width: 20, + }), + }]; + app.rendered_terminal_bounds.insert(surface.id, current); + app.selection = Some(Selection { surface: surface.id, anchor: (7, 0), head: (7, 0) }); + app.drag = Some(Drag::Select { + content: Rect { x: 2, y: 3, width: 10, height: 5 }, + source_x: 0, + auto_scroll: None, + col: 1, + }); + + app.handle_left_drag(23, 6).unwrap(); + + assert_eq!(app.selection.map(|selection| selection.head), Some((10, 2))); + assert!(matches!( + app.drag, + Some(Drag::Select { + content, + source_x: 7, + col: 10, + .. + }) if content == current + )); + mux.close_surface(surface.id).unwrap(); + } + #[test] fn pty_drag_motion_and_release_bypass_pending_mutation_with_pinned_surface() { let mux = Mux::new("pty-release-barrier-test", SurfaceOptions::default()); @@ -18486,6 +21444,7 @@ mod tests { omnibar: None, content, track: None, + viewport: None, }); app.rendered_terminal_bounds.insert(surface.id, content); assert!(app.pty_input.shutdown(Duration::from_secs(1))); @@ -18536,6 +21495,7 @@ mod tests { omnibar: None, content: Rect { x: 13, y: 2, width: 23, height: 6 }, track: None, + viewport: None, }); let mut terminal = Terminal::new(TestBackend::new(40, 12)).unwrap(); @@ -18625,6 +21585,7 @@ mod tests { omnibar: None, content: Rect { x: 1, y: 1, width: 20, height: 8 }, track: None, + viewport: None, }); for plugin in [false, true] { @@ -18697,6 +21658,7 @@ mod tests { omnibar: Some(Rect { x: 0, y: 1, width: 40, height: 1 }), content: Rect { x: 0, y: 2, width: 40, height: 10 }, track: None, + viewport: None, } } @@ -18733,6 +21695,8 @@ mod tests { layout: Node::Leaf(2), active_pane: 2, zoomed_pane: None, + viewport_base_width: None, + viewport_splits: BTreeMap::new(), panes: vec![PaneView { id: 2, short_id: "000002".to_string(), @@ -21960,7 +24924,9 @@ mod tests { }) .unwrap(); let (events, receiver) = std::sync::mpsc::sync_channel(4_096); - let session = OrderedSession::new(session, pty_input.sender(), events.clone()); + let layout_resize_owner = session.allocate_layout_resize_owner(); + let session = + OrderedSession::new(session, pty_input.sender(), events.clone(), layout_resize_owner); let app = App { session, session_event_worker: None, @@ -21987,6 +24953,7 @@ mod tests { tree: TreeView::default(), tab_locations: HashMap::new(), render_states: HashMap::::new(), + chrome_row_scratch: crate::ui::ReusableRowBuffer::default(), rendered_terminal_sizes: HashMap::new(), desired_outer_cursor: OuterCursorSpec::Reset, applied_outer_cursor: None, @@ -21994,6 +24961,12 @@ mod tests { graphics_supported: false, stdout_lock: Arc::new(StdoutLock::new(())), pane_areas: Vec::new(), + viewport_projection: ViewportPaneAreaProjection::default(), + viewport_layout: Vec::new(), + viewport_stacked_headers: HashSet::new(), + viewport_states: HashMap::new(), + viewport_virtual_width: 0, + viewport_offset: 0, pane_focus_history: PaneFocusHistory::default(), rendered_terminal_bounds: HashMap::new(), visible_size_surfaces: HashSet::new(), @@ -22089,6 +25062,8 @@ mod tests { layout: Node::Leaf(2), active_pane: 2, zoomed_pane: None, + viewport_base_width: None, + viewport_splits: BTreeMap::new(), panes: vec![PaneView { id: 2, short_id: "000002".to_string(), @@ -22131,6 +25106,35 @@ mod tests { ); } + #[test] + fn remote_tree_refresh_keeps_restored_zoom_and_focus_aligned() { + let mut previous = notify_tree(11, false); + let screen = &mut previous.workspaces[0].screens[0]; + let mut second = screen.panes[0].clone(); + second.id = 5; + second.tabs[0].surface = 12; + screen.panes.push(second); + screen.layout = Node::Split { + id: 9, + dir: SplitDir::Right, + ratio: 0.5, + a: Box::new(Node::Leaf(2)), + b: Box::new(Node::Leaf(5)), + }; + screen.active_pane = 5; + + let mut restored = previous.clone(); + let restored_screen = &mut restored.workspaces[0].screens[0]; + restored_screen.zoomed_pane = Some(2); + restored_screen.active_pane = 2; + + preserve_client_view(&previous, &mut restored); + + let restored_screen = &restored.workspaces[0].screens[0]; + assert_eq!(restored_screen.zoomed_pane, Some(2)); + assert_eq!(restored_screen.active_pane, 2); + } + fn row_contains(buffer: &ratatui::buffer::Buffer, y: u16, needle: &str) -> bool { (0..buffer.area.width).any(|x| buffer[(x, y)].symbol() == needle) } diff --git a/cmux-tui/crates/cmux-tui/src/cli.rs b/cmux-tui/crates/cmux-tui/src/cli.rs index 0b41cfbbf58e..a32b16dedcdc 100644 --- a/cmux-tui/crates/cmux-tui/src/cli.rs +++ b/cmux-tui/crates/cmux-tui/src/cli.rs @@ -3,9 +3,16 @@ use std::io::{self, BufRead, BufReader, Read, Write}; use std::path::PathBuf; use std::time::{Duration, Instant}; -use cmux_tui_core::{platform::transport, server::CLEAR_HISTORY_CAPABILITY}; +use cmux_tui_core::platform::transport; +use cmux_tui_core::server::{ + CLEAR_HISTORY_CAPABILITY, LAYOUT_UNDO_CAPABILITY, VIEWPORT_COLUMN_RESIZE_CAPABILITY, + VIEWPORT_SPLITS_CAPABILITY, +}; +use cmux_tui_core::{LayoutRatioError, LayoutUndoError, LayoutUndoResult, ViewportWidthError}; use serde_json::{Value, json}; +use crate::localization::{Catalog, LayoutMessages}; + const REQUEST_ID: u64 = 1; const CAPABILITY_REQUEST_ID: u64 = 0; const ATTACH_INITIAL_SIZE_CAPABILITY: &str = "attach-initial-size"; @@ -13,6 +20,7 @@ const CLIENT_SIZING_PROTOCOL: u64 = 10; type BuildFn = fn(&FlagMap) -> Result; type PrintFn = fn(&Value, &mut dyn Write) -> io::Result<()>; +type ValidateFn = fn(&Value) -> io::Result<()>; type LocalFn = fn(&GlobalArgs, &FlagMap) -> i32; #[derive(Debug)] @@ -48,20 +56,26 @@ struct VerbSpec { enum HelpText { Literal(&'static str), ClearHistory, + NewPaneRight, + SetViewportPaneWidth, + UndoLayout, } impl HelpText { - fn resolve(self, catalog: &'static crate::localization::Catalog) -> &'static str { + fn resolve(self, catalog: &'static Catalog) -> &'static str { match self { Self::Literal(text) => text, Self::ClearHistory => catalog.terminal.clear_history_help, + Self::NewPaneRight => catalog.layout.new_pane_right_help, + Self::SetViewportPaneWidth => catalog.layout.set_viewport_pane_width_help, + Self::UndoLayout => catalog.layout.undo_layout_help, } } } #[derive(Clone, Copy)] enum VerbKind { - Socket { build: BuildFn, print: PrintFn, stream: bool }, + Socket { build: BuildFn, print: PrintFn, validate: Option, stream: bool }, Local(LocalFn), } @@ -246,6 +260,12 @@ const VERBS: &[VerbSpec] = &[ allowed: &["pane", "cols", "rows"], kind: socket(build_new_pane, print_surface, false), }, + VerbSpec { + name: "new-pane-right", + help: HelpText::NewPaneRight, + allowed: &["pane", "width", "cols", "rows"], + kind: socket(build_new_pane_right, print_surface, false), + }, VerbSpec { name: "split", help: HelpText::Literal("Split a pane."), @@ -264,6 +284,18 @@ const VERBS: &[VerbSpec] = &[ allowed: &["split", "ratio"], kind: socket(build_set_split_ratio, print_empty, false), }, + VerbSpec { + name: "set-viewport-pane-width", + help: HelpText::SetViewportPaneWidth, + allowed: &["pane", "width"], + kind: socket(build_set_viewport_pane_width, print_empty, false), + }, + VerbSpec { + name: "undo-layout", + help: HelpText::UndoLayout, + allowed: &["pane", "revision", "confirm-close"], + kind: validated_socket(build_undo_layout, print_layout_undo, validate_layout_undo, false), + }, VerbSpec { name: "pane-neighbor", help: HelpText::Literal("Find a pane neighbor."), @@ -423,7 +455,16 @@ const VERBS: &[VerbSpec] = &[ ]; const fn socket(build: BuildFn, print: PrintFn, stream: bool) -> VerbKind { - VerbKind::Socket { build, print, stream } + VerbKind::Socket { build, print, validate: None, stream } +} + +const fn validated_socket( + build: BuildFn, + print: PrintFn, + validate: ValidateFn, + stream: bool, +) -> VerbKind { + VerbKind::Socket { build, print, validate: Some(validate), stream } } pub fn is_cli_invocation(args: &[String]) -> bool { @@ -445,13 +486,18 @@ pub fn run(args: &[String], usage: &str) -> i32 { } pub fn print_help(usage: &str) { - let catalog = crate::localization::catalog(); - print!("{usage}"); - println!(); - println!("VERB HELP"); + let mut stdout = io::stdout().lock(); + let _ = write_help(usage, crate::localization::catalog(), &mut stdout); +} + +fn write_help(usage: &str, catalog: &'static Catalog, out: &mut dyn Write) -> io::Result<()> { + write!(out, "{usage}")?; + writeln!(out)?; + writeln!(out, "{}", catalog.layout.verb_help_heading)?; for verb in VERBS { - println!(" {:<18} {}", verb.name, verb.help.resolve(catalog)); + writeln!(out, " {:<18} {}", verb.name, verb.help.resolve(catalog))?; } + Ok(()) } enum FirstCommand { @@ -575,8 +621,8 @@ fn verb_by_name(name: &str) -> Option<&'static VerbSpec> { } fn run_command(args: CliArgs) -> i32 { - let (build, print, stream_mode) = match args.verb.kind { - VerbKind::Socket { build, print, stream } => (build, print, stream), + let (build, print, validate, stream_mode) = match args.verb.kind { + VerbKind::Socket { build, print, validate, stream } => (build, print, validate, stream), VerbKind::Local(run) => return run(&args.global, &args.flags), }; let request = match build(&args.flags) { @@ -639,25 +685,36 @@ fn run_command(args: CliArgs) -> i32 { if stream_mode { run_stream(reader) } else { - run_one_response(&mut reader, args.global.json, print) + run_one_response(&mut reader, args.global.json, print, validate) } } -fn required_capability(request: &Value) -> Option<(&'static str, &'static str)> { +fn required_capability(request: &Value) -> Option<(&'static str, String)> { required_capability_with_catalog(request, crate::localization::catalog()) } fn required_capability_with_catalog( request: &Value, - catalog: &'static crate::localization::Catalog, -) -> Option<(&'static str, &'static str)> { + catalog: &'static Catalog, +) -> Option<(&'static str, String)> { match request.get("cmd").and_then(Value::as_str) { Some("attach-surface") if request.get("cols").is_some() => Some(( ATTACH_INITIAL_SIZE_CAPABILITY, - "initial attach sizing is not supported by this server", + "initial attach sizing is not supported by this server".to_string(), )), Some("clear-history") => { - Some((CLEAR_HISTORY_CAPABILITY, catalog.terminal.clear_history_unsupported)) + Some((CLEAR_HISTORY_CAPABILITY, catalog.terminal.clear_history_unsupported.to_string())) + } + Some("new-pane-right") => Some(( + VIEWPORT_SPLITS_CAPABILITY, + catalog.layout.unsupported_server_command("new-pane-right"), + )), + Some("set-viewport-pane-width") => Some(( + VIEWPORT_COLUMN_RESIZE_CAPABILITY, + catalog.layout.unsupported_server_command("set-viewport-pane-width"), + )), + Some("undo-layout") => { + Some((LAYOUT_UNDO_CAPABILITY, catalog.layout.unsupported_server_command("undo-layout"))) } _ => None, } @@ -718,11 +775,7 @@ fn server_identity(reader: &mut BufReader>) -> Result continue; } if value.get("ok").and_then(Value::as_bool) != Some(true) { - return Err(value - .get("error") - .and_then(Value::as_str) - .unwrap_or("identify failed") - .to_string()); + return Err("server rejected identify request".to_string()); } return value .get("data") @@ -734,6 +787,7 @@ fn server_identity(reader: &mut BufReader>) -> Result fn is_boolean_flag(spec: &VerbSpec, name: &str) -> bool { (spec.name == "run" && name == "new-workspace") || (spec.name == "send" && name == "paste") + || (spec.name == "undo-layout" && name == "confirm-close") || (spec.name == "plugin" && matches!(name, "force" | "builtin")) } @@ -770,6 +824,7 @@ fn run_one_response( reader: &mut BufReader>, json_output: bool, print_human: PrintFn, + validate: Option, ) -> i32 { loop { let mut line = String::new(); @@ -794,7 +849,7 @@ fn run_one_response( if value.get("event").is_some() { continue; } - return print_response(&value, json_output, print_human); + return print_response(&value, json_output, print_human, validate); } } @@ -857,13 +912,24 @@ fn run_stream(mut reader: BufReader>) -> i32 { } } -fn print_response(value: &Value, json_output: bool, print_human: PrintFn) -> i32 { +fn print_response( + value: &Value, + json_output: bool, + print_human: PrintFn, + validate: Option, +) -> i32 { if value.get("ok").and_then(Value::as_bool) != Some(true) { - let error = value.get("error").and_then(Value::as_str).unwrap_or("unknown error"); + let error = localized_response_error(value); eprintln!("{error}"); return 1; } let data = value.get("data").unwrap_or(&Value::Null); + if let Some(validate) = validate + && let Err(error) = validate(data) + { + eprintln!("{error}"); + return 3; + } let mut stdout = io::stdout(); let result = if json_output { serde_json::to_writer(&mut stdout, data) @@ -881,6 +947,33 @@ fn print_response(value: &Value, json_output: bool, print_human: PrintFn) -> i32 } } +fn localized_response_error(value: &Value) -> String { + localized_response_error_for(value, crate::localization::catalog()) +} + +fn localized_response_error_for(value: &Value, catalog: &Catalog) -> String { + let code = value.get("error_code").and_then(Value::as_str); + match code { + Some(LayoutUndoError::UNAVAILABLE_CODE) => { + catalog.sidebar.layout_nothing_to_undo.to_string() + } + Some(LayoutUndoError::STALE_CODE) => catalog.sidebar.layout_undo_stale.to_string(), + Some(LayoutRatioError::UNKNOWN_TARGET_CODE) => { + catalog.layout.viewport_ratio_target_missing.to_string() + } + Some(LayoutRatioError::OUT_OF_RANGE_CODE) => { + catalog.layout.viewport_ratio_out_of_range.to_string() + } + Some(ViewportWidthError::OUT_OF_RANGE_CODE) => { + catalog.layout.viewport_width_out_of_range.to_string() + } + Some(ViewportWidthError::COLUMN_MISSING_CODE) => { + catalog.layout.viewport_column_missing.to_string() + } + _ => value.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string(), + } +} + fn build_no_args(flags: &FlagMap) -> Result { flags.reject_remaining()?; Ok(json!({})) @@ -1136,6 +1229,14 @@ fn build_new_pane(flags: &FlagMap) -> Result { Ok(value) } +fn build_new_pane_right(flags: &FlagMap) -> Result { + let mut value = build_new_pane(flags)?; + if flags.optional("width").is_some() { + value["width"] = json!(required_viewport_width(flags)?); + } + Ok(value) +} + fn build_export_layout(flags: &FlagMap) -> Result { let mut value = json!({}); flags.insert_optional_u64(&mut value, "screen")?; @@ -1161,17 +1262,86 @@ fn build_set_ratio(flags: &FlagMap) -> Result { Ok(json!({ "pane": flags.required_u64("pane")?, "dir": flags.required_dir()?, - "ratio": flags.required_f32("ratio")?, + "ratio": required_ratio(flags)?, })) } fn build_set_split_ratio(flags: &FlagMap) -> Result { Ok(json!({ "split": flags.required_u64("split")?, - "ratio": flags.required_f32("ratio")?, + "ratio": required_ratio(flags)?, + })) +} + +fn required_ratio(flags: &FlagMap) -> Result { + required_ratio_with_messages(flags, &crate::localization::catalog().layout) +} + +fn required_ratio_with_messages( + flags: &FlagMap, + messages: &LayoutMessages, +) -> Result { + let ratio = flags + .required("ratio")? + .parse::() + .map_err(|_| UsageError(messages.ratio_must_be_number.to_string()))?; + if !ratio.is_finite() { + return Err(UsageError(messages.ratio_must_be_finite.to_string())); + } + Ok(ratio) +} + +fn build_set_viewport_pane_width(flags: &FlagMap) -> Result { + Ok(json!({ + "pane": flags.required_u64("pane")?, + "width": required_viewport_width(flags)?, })) } +fn build_undo_layout(flags: &FlagMap) -> Result { + let revision = flags.optional("revision"); + let confirm_close = flags.optional("confirm-close").is_some(); + if revision.is_some() != confirm_close { + return Err(UsageError( + crate::localization::catalog() + .layout + .layout_undo_confirmation_flags_together + .to_string(), + )); + } + let mut value = json!({ + "pane": flags.required_u64("pane")?, + "confirm_close": confirm_close, + }); + if let Some(revision) = revision { + value["revision"] = json!(parse_u64("revision", &revision)?); + } + Ok(value) +} + +fn required_viewport_width(flags: &FlagMap) -> Result { + required_viewport_width_with_messages(flags, &crate::localization::catalog().layout) +} + +fn required_viewport_width_with_messages( + flags: &FlagMap, + messages: &LayoutMessages, +) -> Result { + let width = flags + .required("width")? + .parse::() + .map_err(|_| UsageError(messages.viewport_width_must_be_number.to_string()))?; + if !width.is_finite() { + return Err(UsageError(messages.viewport_width_must_be_finite.to_string())); + } + if !(cmux_tui_core::MIN_VIEWPORT_PANE_WIDTH..=cmux_tui_core::MAX_VIEWPORT_PANE_WIDTH) + .contains(&width) + { + return Err(UsageError(messages.viewport_width_out_of_range.to_string())); + } + Ok(width) +} + fn build_pane_direction(flags: &FlagMap) -> Result { Ok(json!({ "pane": flags.required_u64("pane")?, "dir": flags.required_direction()? })) } @@ -1322,12 +1492,6 @@ impl FlagMap { parse_isize(name, &self.required(name)?) } - fn required_f32(&self, name: &str) -> Result { - self.required(name)? - .parse::() - .map_err(|_| UsageError(format!("--{name} must be a number"))) - } - fn required_dir(&self) -> Result { let dir = self.required("dir")?; if dir == "right" || dir == "down" { @@ -1528,6 +1692,28 @@ fn print_surface(data: &Value, out: &mut dyn Write) -> io::Result<()> { writeln!(out, "{}", data.get("surface").and_then(Value::as_u64).unwrap_or(0)) } +fn decode_layout_undo(data: &Value) -> io::Result { + crate::layout_undo::decode_layout_undo_result(data, &crate::localization::catalog().layout) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string())) +} + +fn validate_layout_undo(data: &Value) -> io::Result<()> { + decode_layout_undo(data).map(|_| ()) +} + +fn print_layout_undo(data: &Value, out: &mut dyn Write) -> io::Result<()> { + let messages = &crate::localization::catalog().layout; + match decode_layout_undo(data)? { + LayoutUndoResult::Undone { screen, revision } => { + writeln!(out, "{}", messages.layout_undo_applied(screen, revision)) + } + LayoutUndoResult::ConfirmationRequired { revision, closes_panes, .. } => { + let panes = closes_panes.iter().map(u64::to_string).collect::>().join(","); + writeln!(out, "{}", messages.layout_undo_confirmation_required(revision, &panes)) + } + } +} + fn print_notification(data: &Value, out: &mut dyn Write) -> io::Result<()> { writeln!(out, "{}", data.get("notification").and_then(Value::as_u64).unwrap_or(0)) } @@ -1800,6 +1986,23 @@ mod tests { ); } + #[test] + fn capability_probe_does_not_expose_server_error_text() { + let stream = ScriptedStream { + reads: VecDeque::from([Ok( + b"{\"id\":0,\"ok\":false,\"error\":\"private upstream detail\"}\n".to_vec(), + )]), + current: io::Cursor::new(Vec::new()), + writes: Vec::new(), + }; + let mut reader = BufReader::new(Box::new(stream) as Box); + + let error = + server_supports_capability(&mut reader, ATTACH_INITIAL_SIZE_CAPABILITY).unwrap_err(); + assert_eq!(error, "server rejected identify request"); + assert!(!error.contains("private upstream detail")); + } + #[test] fn clear_history_requires_its_additive_capability() { let request = json!({"id": 1, "cmd": "clear-history", "surface": 9}); @@ -1810,7 +2013,8 @@ mod tests { ), Some(( CLEAR_HISTORY_CAPABILITY, - "clear-history is not supported by this server; restart the cmux-tui server", + "clear-history is not supported by this server; restart the cmux-tui server" + .to_string(), )) ); @@ -1839,7 +2043,8 @@ mod tests { ), Some(( CLEAR_HISTORY_CAPABILITY, - "このサーバーでは clear-history を使用できません。cmux-tui サーバーを再起動してください", + "このサーバーでは clear-history を使用できません。cmux-tui サーバーを再起動してください" + .to_string(), )) ); } @@ -1865,6 +2070,196 @@ mod tests { assert!(VERBS.iter().all(|verb| !verb.help.resolve(catalog).is_empty())); } + #[test] + fn layout_cli_help_and_typed_errors_use_the_selected_catalog() { + let japanese = crate::localization::catalog_for_locale("ja_JP.UTF-8"); + let mut help = Vec::new(); + write_help("usage\n", japanese, &mut help).unwrap(); + let help = String::from_utf8(help).unwrap(); + assert!(help.contains("コマンドヘルプ")); + assert!(!help.contains("VERB HELP")); + assert!(help.contains("右側にビューポートペインを作成")); + assert!(help.contains("直前のレイアウト変更を元に戻す")); + + let error = localized_response_error_for( + &json!({ + "ok": false, + "error_code": LayoutRatioError::OUT_OF_RANGE_CODE, + "error": "private English server detail", + }), + japanese, + ); + assert_eq!(error, japanese.layout.viewport_ratio_out_of_range); + assert!(!error.contains("private English")); + + let width_error = localized_response_error_for( + &json!({ + "ok": false, + "error_code": ViewportWidthError::OUT_OF_RANGE_CODE, + "error": "private English width detail", + }), + japanese, + ); + assert_eq!(width_error, japanese.layout.viewport_width_out_of_range); + assert!(!width_error.contains("private English")); + + let unavailable = localized_response_error_for( + &json!({ + "ok": false, + "error_code": LayoutUndoError::UNAVAILABLE_CODE, + "error": "no layout change to undo", + }), + japanese, + ); + assert_eq!(unavailable, japanese.sidebar.layout_nothing_to_undo); + } + + #[test] + fn viewport_pane_builder_accepts_an_optional_width() { + let flags = FlagMap { + values: BTreeMap::from([ + ("pane".to_string(), "7".to_string()), + ("width".to_string(), "0.75".to_string()), + ("cols".to_string(), "60".to_string()), + ("rows".to_string(), "20".to_string()), + ]), + ..Default::default() + }; + assert_eq!( + build_new_pane_right(&flags).unwrap(), + json!({"pane": 7, "width": 0.75, "cols": 60, "rows": 20}) + ); + } + + #[test] + fn viewport_width_builders_reject_nonfinite_and_out_of_range_values() { + for width in ["NaN", "inf", "0.09", "1.01"] { + let flags = FlagMap { + values: BTreeMap::from([ + ("pane".to_string(), "7".to_string()), + ("width".to_string(), width.to_string()), + ]), + ..Default::default() + }; + assert!(build_new_pane_right(&flags).is_err(), "accepted width {width}"); + assert!(build_set_viewport_pane_width(&flags).is_err(), "accepted width {width}"); + } + } + + #[test] + fn viewport_width_builder_localizes_nonfinite_values() { + let japanese = crate::localization::catalog_for_locale("ja_JP.UTF-8"); + let flags = |width: &str| FlagMap { + values: BTreeMap::from([("width".to_string(), width.to_string())]), + ..Default::default() + }; + + let error = + required_viewport_width_with_messages(&flags("NaN"), &japanese.layout).unwrap_err(); + + assert_eq!(error.0, japanese.layout.viewport_width_must_be_finite); + assert!(!error.0.contains("must be a finite number")); + + let error = + required_viewport_width_with_messages(&flags("wide"), &japanese.layout).unwrap_err(); + assert_eq!(error.0, japanese.layout.viewport_width_must_be_number); + } + + #[test] + fn layout_undo_builder_and_printer_preserve_the_confirmation_revision() { + let flags = FlagMap { + values: BTreeMap::from([ + ("pane".to_string(), "7".to_string()), + ("revision".to_string(), "42".to_string()), + ("confirm-close".to_string(), "true".to_string()), + ]), + ..Default::default() + }; + assert_eq!( + build_undo_layout(&flags).unwrap(), + json!({"pane": 7, "revision": 42, "confirm_close": true}) + ); + + let mut output = Vec::new(); + print_layout_undo( + &json!({ + "undone": false, + "confirmation_required": true, + "screen": 3, + "revision": 42, + "closes_panes": [7, 8], + }), + &mut output, + ) + .unwrap(); + assert_eq!( + String::from_utf8(output).unwrap(), + "confirmation required: rerun with --revision 42 --confirm-close (closes panes 7,8)\n" + ); + } + + #[test] + fn layout_undo_printer_rejects_malformed_results() { + for data in [ + json!({ + "undone": false, + "confirmation_required": true, + "screen": 3, + "revision": 42, + }), + json!({ + "confirmation_required": true, + "screen": 3, + "revision": 42, + "closes_panes": [7], + }), + json!({ + "undone": true, + "confirmation_required": true, + "screen": 3, + "revision": 42, + "closes_panes": [7], + }), + json!({ + "undone": false, + "confirmation_required": true, + "revision": 42, + "closes_panes": [7], + }), + json!({ + "undone": false, + "confirmation_required": true, + "screen": 3, + "revision": 42, + "closes_panes": [7, "8"], + }), + ] { + let mut output = Vec::new(); + assert!(print_layout_undo(&data, &mut output).is_err(), "accepted {data}"); + assert!(output.is_empty()); + } + } + + #[test] + fn layout_undo_requires_confirmation_flags_together() { + for values in [ + BTreeMap::from([ + ("pane".to_string(), "7".to_string()), + ("revision".to_string(), "42".to_string()), + ]), + BTreeMap::from([ + ("pane".to_string(), "7".to_string()), + ("confirm-close".to_string(), "true".to_string()), + ]), + ] { + let error = build_undo_layout(&FlagMap { values, ..Default::default() }).unwrap_err(); + assert_eq!( + error.0, + crate::localization::catalog().layout.layout_undo_confirmation_flags_together + ); + } + } + #[test] fn run_workspace_key_requires_atomic_workspace_creation() { let flags = FlagMap { diff --git a/cmux-tui/crates/cmux-tui/src/config.rs b/cmux-tui/crates/cmux-tui/src/config.rs index 024febec117d..ffb639a9af98 100644 --- a/cmux-tui/crates/cmux-tui/src/config.rs +++ b/cmux-tui/crates/cmux-tui/src/config.rs @@ -64,6 +64,9 @@ //! "scrollbar": { //! "position": "column" //! }, +//! "viewport": { +//! "animation": true +//! }, //! "server": { //! "ws": "127.0.0.1:7681", //! "ws_token": "replace-with-a-secret" @@ -98,7 +101,7 @@ //! `select-screen-0` through `select-screen-9`, `new-screen`, //! `prev-workspace`, `next-workspace`, `new-workspace`, `close-workspace`, //! `send-prefix`, `toggle-sidebar`, `toggle-sidebar-compact`, -//! `toggle-sidebar-view`, `focus-sidebar`, +//! `toggle-sidebar-view`, `focus-sidebar`, `new-pane-right`, `undo-layout`, //! `focus-left`, `focus-right`, `focus-up`, `focus-down`, `focus-next-pane`, //! `swap-pane-prev`, `swap-pane-next`, `zoom-pane`, `resize-grow`, //! `resize-shrink`, `scroll-up`, `scroll-down`, `clear-history`, `browser-back`, @@ -164,6 +167,8 @@ struct RawConfig { #[serde(default)] scrollbar: RawScrollbar, #[serde(default)] + viewport: RawViewport, + #[serde(default)] server: RawServer, /// Key bindings: `"prefix"` plus one entry per action. Values may be /// a chord string, an array of chord strings, `"none"`, or @@ -573,6 +578,12 @@ struct RawScrollbar { position: Option, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawViewport { + animation: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ScrollbarPosition { @@ -591,6 +602,17 @@ impl Default for Scrollbar { } } +#[derive(Debug, Clone, Copy)] +pub struct Viewport { + pub animation: bool, +} + +impl Default for Viewport { + fn default() -> Self { + Self { animation: true } + } +} + /// A color in the config file: "#rrggbb", "#rgb", or an xterm-256 index. #[derive(Debug, Deserialize)] #[serde(untagged)] @@ -860,6 +882,8 @@ pub enum Action { ToggleSidebarCompact, ToggleSidebarView, FocusSidebar, + NewPaneRight, + UndoLayout, FocusLeft, FocusRight, FocusUp, @@ -937,6 +961,8 @@ define_named_action_definitions! { TOGGLE_SIDEBAR_COMPACT_DEFINITION => (Action::ToggleSidebarCompact, "toggle-sidebar-compact", "Compact or expand sidebar", "サイドバーの幅を切り替え"); TOGGLE_SIDEBAR_VIEW_DEFINITION => (Action::ToggleSidebarView, "toggle-sidebar-view", "Switch sidebar view", "サイドバー表示を切り替え"); FOCUS_SIDEBAR_DEFINITION => (Action::FocusSidebar, "focus-sidebar", "Focus sidebar", "サイドバーにフォーカス"); + NEW_PANE_RIGHT_DEFINITION => (Action::NewPaneRight, "new-pane-right", "New column to the right", "右に新しい列"); + UNDO_LAYOUT_DEFINITION => (Action::UndoLayout, "undo-layout", "Undo layout", "レイアウトを元に戻す"); FOCUS_LEFT_DEFINITION => (Action::FocusLeft, "focus-left", "Focus left", "左へフォーカス"); FOCUS_RIGHT_DEFINITION => (Action::FocusRight, "focus-right", "Focus right", "右へフォーカス"); FOCUS_UP_DEFINITION => (Action::FocusUp, "focus-up", "Focus up", "上へフォーカス"); @@ -1087,7 +1113,7 @@ static SELECT_SCREEN_DEFINITIONS: [ActionDefinition; 10] = [ /// The canonical action catalog. Presentation surfaces derive their labels /// and ordering from these named definitions instead of positional offsets. pub fn action_definitions() -> &'static [&'static ActionDefinition] { - static DEFINITIONS: [&ActionDefinition; 64] = [ + static DEFINITIONS: [&ActionDefinition; 66] = [ &SEND_PREFIX_DEFINITION, &NEW_TAB_DEFINITION, &NEW_BROWSER_TAB_DEFINITION, @@ -1133,6 +1159,8 @@ pub fn action_definitions() -> &'static [&'static ActionDefinition] { &TOGGLE_SIDEBAR_COMPACT_DEFINITION, &TOGGLE_SIDEBAR_VIEW_DEFINITION, &FOCUS_SIDEBAR_DEFINITION, + &NEW_PANE_RIGHT_DEFINITION, + &UNDO_LAYOUT_DEFINITION, &FOCUS_LEFT_DEFINITION, &FOCUS_RIGHT_DEFINITION, &FOCUS_UP_DEFINITION, @@ -1186,6 +1214,8 @@ impl Action { Action::ToggleSidebarCompact => &TOGGLE_SIDEBAR_COMPACT_DEFINITION, Action::ToggleSidebarView => &TOGGLE_SIDEBAR_VIEW_DEFINITION, Action::FocusSidebar => &FOCUS_SIDEBAR_DEFINITION, + Action::NewPaneRight => &NEW_PANE_RIGHT_DEFINITION, + Action::UndoLayout => &UNDO_LAYOUT_DEFINITION, Action::FocusLeft => &FOCUS_LEFT_DEFINITION, Action::FocusRight => &FOCUS_RIGHT_DEFINITION, Action::FocusUp => &FOCUS_UP_DEFINITION, @@ -1381,6 +1411,8 @@ impl Default for Keys { bind(KeyCode::Char('m'), Action::ToggleSidebarCompact), bind(KeyCode::Char('e'), Action::ToggleSidebarView), bind(KeyCode::Char('S'), Action::FocusSidebar), + bind(KeyCode::Char('g'), Action::NewPaneRight), + bind(KeyCode::Char('U'), Action::UndoLayout), bind(KeyCode::Char('o'), Action::FocusNextPane), bind(KeyCode::Char('h'), Action::FocusLeft), bind(KeyCode::Left, Action::FocusLeft), @@ -1427,6 +1459,15 @@ impl Keys { || (action == Action::ClearHistory && chord.mods.contains(KeyModifiers::CONTROL)) } + fn shortcut_label_for_chord(&self, action: Action, chord: &Chord) -> Option { + let chord_label = chord.display_label()?; + if self.is_modeless_binding(chord, action) { + Some(chord_label) + } else { + Some(format!("{} {chord_label}", self.prefix.display_label()?)) + } + } + /// The action bound to a key event (after the prefix). pub fn action_for(&self, key: &KeyEvent) -> Option { self.bindings.iter().find(|(chord, _)| chord.matches(key)).map(|(_, a)| *a) @@ -1454,14 +1495,7 @@ impl Keys { self.bindings .iter() .filter(|(_, bound)| *bound == action) - .filter_map(|(chord, _)| { - let chord_label = chord.display_label()?; - if self.is_modeless_binding(chord, action) { - Some(chord_label) - } else { - Some(format!("{} {chord_label}", self.prefix.display_label()?)) - } - }) + .filter_map(|(chord, _)| self.shortcut_label_for_chord(action, chord)) .collect() } @@ -1477,12 +1511,20 @@ impl Keys { /// Bound actions in canonical catalog order, ready for shortcut help and /// future command surfaces. pub fn resolved_shortcuts(&self) -> Vec<(&'static ActionDefinition, Vec)> { + let mut shortcuts_by_action = HashMap::>::new(); + for (chord, action) in &self.bindings { + if let Some(label) = self.shortcut_label_for_chord(*action, chord) { + shortcuts_by_action.entry(*action).or_default().push(label); + } + } action_definitions() .iter() .copied() .filter_map(|definition| { - let shortcuts = self.shortcut_labels(definition.action); - (!shortcuts.is_empty()).then_some((definition, shortcuts)) + shortcuts_by_action + .remove(&definition.action) + .filter(|shortcuts| !shortcuts.is_empty()) + .map(|shortcuts| (definition, shortcuts)) }) .collect() } @@ -1647,6 +1689,7 @@ pub struct Config { pub machines: Vec, pub browser: Browser, pub scrollbar: Scrollbar, + pub viewport: Viewport, pub server: Server, pub keys: Keys, } @@ -1912,6 +1955,9 @@ pub fn load() -> Config { if let Some(position) = raw.scrollbar.position { config.scrollbar.position = position; } + if let Some(animation) = raw.viewport.animation { + config.viewport.animation = animation; + } config.server.ws = raw.server.ws.filter(|value| !value.trim().is_empty()); config.server.ws_token = raw.server.ws_token.filter(|value| !value.trim().is_empty()); config.keys.apply(&raw.keys); @@ -2893,6 +2939,7 @@ mod tests { } ], "scrollbar": {"position": "border"}, + "viewport": {"animation": false}, "keys": { "alt_shortcuts": false, "rename-pane": "r", @@ -2950,6 +2997,7 @@ mod tests { assert_eq!(plugin.command, vec!["/tmp/sidebar-plugin", "--mode", "test"]); assert_eq!(plugin.cwd.as_deref(), Some("/tmp")); assert_eq!(config.scrollbar.position, ScrollbarPosition::Border); + assert!(!config.viewport.animation); assert_eq!( config.keys.action_for(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), Some(Action::RenameTab) @@ -2995,6 +3043,21 @@ mod tests { assert!(err.contains("unknown variant `stealth`"), "{err}"); } + #[test] + fn viewport_animation_defaults_on_and_can_be_disabled() { + let raw: RawConfig = serde_json::from_str(r#"{}"#).unwrap(); + assert!(raw.viewport.animation.is_none()); + assert!(Config::default().viewport.animation); + + let raw: RawConfig = serde_json::from_str(r#"{"viewport":{"animation":false}}"#).unwrap(); + assert_eq!(raw.viewport.animation, Some(false)); + + let error = serde_json::from_str::(r#"{"viewport":{"animation":"slow"}}"#) + .unwrap_err() + .to_string(); + assert!(error.contains("invalid type"), "{error}"); + } + #[test] fn config_path_prefers_cmux_tui_json_and_falls_back_to_legacy_mux_json() { let _guard = CONFIG_ENV_LOCK.lock().unwrap(); @@ -3263,6 +3326,15 @@ mod tests { ); } + #[test] + fn layout_undo_has_a_default_prefix_binding() { + let keys = Keys::default(); + assert_eq!( + keys.action_for(&KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT)), + Some(Action::UndoLayout) + ); + } + #[test] fn new_action_names_parse_from_config_overrides() { let cases = [ @@ -3273,6 +3345,8 @@ mod tests { ("scroll-up", Action::ScrollUp), ("toggle-sidebar-compact", Action::ToggleSidebarCompact), ("toggle-sidebar-view", Action::ToggleSidebarView), + ("new-pane-right", Action::NewPaneRight), + ("undo-layout", Action::UndoLayout), ("show-shortcuts", Action::ShowShortcuts), ("send-prefix", Action::SendPrefix), ("prev-workspace", Action::PrevWorkspace), diff --git a/cmux-tui/crates/cmux-tui/src/layout_undo.rs b/cmux-tui/crates/cmux-tui/src/layout_undo.rs new file mode 100644 index 000000000000..87ec6d7bcb65 --- /dev/null +++ b/cmux-tui/crates/cmux-tui/src/layout_undo.rs @@ -0,0 +1,113 @@ +use cmux_tui_core::LayoutUndoResult; +use serde_json::Value; + +use crate::localization::LayoutMessages; + +pub(crate) fn decode_layout_undo_result( + value: &Value, + messages: &LayoutMessages, +) -> anyhow::Result { + let screen = value + .get("screen") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!(messages.layout_undo_missing_screen))?; + let revision = value + .get("revision") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!(messages.layout_undo_missing_revision))?; + let undone = value.get("undone").and_then(Value::as_bool); + let confirmation_required = value.get("confirmation_required").and_then(Value::as_bool); + + match (undone, confirmation_required) { + (Some(true), None | Some(false)) => Ok(LayoutUndoResult::Undone { screen, revision }), + (Some(false), Some(true)) => { + let closes_panes = value + .get("closes_panes") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!(messages.layout_undo_missing_closes_panes))? + .iter() + .map(|value| { + value.as_u64().ok_or_else(|| anyhow::anyhow!(messages.layout_undo_invalid_pane)) + }) + .collect::>>()?; + Ok(LayoutUndoResult::ConfirmationRequired { screen, revision, closes_panes }) + } + _ => anyhow::bail!(messages.layout_undo_missing_outcome), + } +} + +#[cfg(test)] +mod tests { + use cmux_tui_core::LayoutUndoResult; + use serde_json::json; + + use super::decode_layout_undo_result; + + #[test] + fn decoder_accepts_only_complete_layout_undo_variants() { + let messages = &crate::localization::catalog_for_locale("en").layout; + assert_eq!( + decode_layout_undo_result( + &json!({"undone": true, "screen": 3, "revision": 9}), + messages, + ) + .unwrap(), + LayoutUndoResult::Undone { screen: 3, revision: 9 } + ); + assert_eq!( + decode_layout_undo_result( + &json!({ + "undone": false, + "confirmation_required": true, + "screen": 3, + "revision": 8, + "closes_panes": [15], + }), + messages, + ) + .unwrap(), + LayoutUndoResult::ConfirmationRequired { + screen: 3, + revision: 8, + closes_panes: vec![15], + } + ); + + for value in [ + json!({ + "undone": false, + "confirmation_required": true, + "screen": 3, + "revision": 8, + }), + json!({ + "confirmation_required": true, + "screen": 3, + "revision": 8, + "closes_panes": [15], + }), + json!({ + "undone": true, + "confirmation_required": true, + "screen": 3, + "revision": 8, + "closes_panes": [15], + }), + json!({ + "undone": false, + "confirmation_required": true, + "revision": 8, + "closes_panes": [15], + }), + json!({ + "undone": false, + "confirmation_required": true, + "screen": 3, + "revision": 8, + "closes_panes": [15, "16"], + }), + ] { + assert!(decode_layout_undo_result(&value, messages).is_err(), "accepted {value}"); + } + } +} diff --git a/cmux-tui/crates/cmux-tui/src/localization.rs b/cmux-tui/crates/cmux-tui/src/localization.rs index 671fe9273557..ea4bf7f19259 100644 --- a/cmux-tui/crates/cmux-tui/src/localization.rs +++ b/cmux-tui/crates/cmux-tui/src/localization.rs @@ -109,6 +109,126 @@ pub(crate) struct ShortcutMessages { pub footer: &'static str, } +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct LayoutMessages { + pub startup_shortcuts: &'static str, + pub verb_help_heading: &'static str, + pub new_pane_right_help: &'static str, + pub set_viewport_pane_width_help: &'static str, + pub undo_layout_help: &'static str, + pub create_viewport_pane_operation: &'static str, + pub undo_layout_operation: &'static str, + pub resize_exact_split_operation: &'static str, + pub split_id_subject: &'static str, + pub resize_viewport_pane_operation: &'static str, + pub viewport_pane_subject: &'static str, + pub remote_viewport_panes_unsupported: &'static str, + pub ratio_must_be_number: &'static str, + pub ratio_must_be_finite: &'static str, + pub viewport_width_must_be_number: &'static str, + pub viewport_width_must_be_finite: &'static str, + pub viewport_width_out_of_range: &'static str, + surface_size_release_failed: &'static str, + pane_without_resizable_column: &'static str, + pub remote_viewport_resize_unsupported: &'static str, + pub remote_layout_undo_unsupported: &'static str, + pub layout_undo_missing_screen: &'static str, + pub layout_undo_missing_revision: &'static str, + pub layout_undo_missing_closes_panes: &'static str, + pub layout_undo_invalid_pane: &'static str, + pub layout_undo_missing_outcome: &'static str, + pub layout_undo_confirmation_flags_together: &'static str, + pub layout_changed_before_undo: &'static str, + unknown_split: &'static str, + unknown_pane_split: &'static str, + unrepresentable_viewport_width: &'static str, + unrepresentable_viewport_ratio: &'static str, + pub viewport_ratio_target_missing: &'static str, + pub viewport_ratio_out_of_range: &'static str, + pub viewport_column_missing: &'static str, + unsupported_server_command: &'static str, + layout_undo_applied: &'static str, + layout_undo_confirmation_required: &'static str, +} + +impl LayoutMessages { + pub(crate) fn surface_size_release_failed(&self, surface: u64, error: &str) -> String { + self.surface_size_release_failed + .replace("{surface}", &surface.to_string()) + .replace("{error}", error) + } + + pub(crate) fn pane_without_resizable_column(&self, pane: u64) -> String { + self.pane_without_resizable_column.replace("{pane}", &pane.to_string()) + } + + pub(crate) fn unknown_split(&self, split: u64) -> String { + self.unknown_split.replace("{split}", &split.to_string()) + } + + pub(crate) fn unknown_pane_split(&self, pane: u64) -> String { + self.unknown_pane_split.replace("{pane}", &pane.to_string()) + } + + pub(crate) fn unrepresentable_viewport_width( + &self, + split: u64, + ratio: f32, + width: f32, + ) -> String { + self.unrepresentable_viewport_width + .replace("{split}", &split.to_string()) + .replace("{ratio}", &ratio.to_string()) + .replace("{width}", &width.to_string()) + } + + pub(crate) fn unrepresentable_viewport_ratio(&self, split: u64, ratio: f32) -> String { + self.unrepresentable_viewport_ratio + .replace("{split}", &split.to_string()) + .replace("{ratio}", &ratio.to_string()) + } + + pub(crate) fn unsupported_server_command(&self, command: &str) -> String { + self.unsupported_server_command.replace("{command}", command) + } + + pub(crate) fn layout_undo_applied(&self, screen: u64, revision: u64) -> String { + self.layout_undo_applied + .replace("{screen}", &screen.to_string()) + .replace("{revision}", &revision.to_string()) + } + + pub(crate) fn layout_undo_confirmation_required(&self, revision: u64, panes: &str) -> String { + self.layout_undo_confirmation_required + .replace("{revision}", &revision.to_string()) + .replace("{panes}", panes) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RuntimeMessages { + pub unknown_panic: &'static str, + renderer_panicked: &'static str, + host_input_failed: &'static str, + terminal_restore_also_failed: &'static str, +} + +impl RuntimeMessages { + pub(crate) fn renderer_panicked(&self, message: &str) -> String { + self.renderer_panicked.replace("{message}", message) + } + + pub(crate) fn host_input_failed(&self, error: &str) -> String { + self.host_input_failed.replace("{error}", error) + } + + pub(crate) fn terminal_restore_also_failed(&self, error: &str, restore_error: &str) -> String { + self.terminal_restore_also_failed + .replace("{error}", error) + .replace("{restore_error}", restore_error) + } +} + #[derive(Debug, PartialEq, Eq)] pub(crate) struct ConfigMessages { invalid_macos_option_as_alt: &'static str, @@ -199,7 +319,10 @@ pub(crate) struct SidebarMessages { pub action_open_private_workspace_port: &'static str, pub action_workspace_port: &'static str, pub confirm_destructive_action: &'static str, + pub confirm_layout_undo: &'static str, pub confirmation_mismatch: &'static str, + pub layout_nothing_to_undo: &'static str, + pub layout_undo_stale: &'static str, pub initial_machine_connection_failed: &'static str, pub provider_notice_identity_unavailable: &'static str, pub provider_connection_already_running: &'static str, @@ -309,6 +432,8 @@ pub(crate) struct Catalog { pub machine_agent: MachineAgentMessages, pub menu: MenuMessages, pub shortcuts: ShortcutMessages, + pub layout: LayoutMessages, + pub runtime: RuntimeMessages, pub config: ConfigMessages, pub attach: AttachMessages, pub sidebar: SidebarMessages, @@ -410,6 +535,52 @@ edits shell files. Authenticate with the configured host before retrying. close_button: "Esc close", footer: "↑/↓ or wheel scroll · Esc or ? close", }, + layout: LayoutMessages { + startup_shortcuts: " g new 2/3 column right U undo layout", + verb_help_heading: "VERB HELP", + new_pane_right_help: "Create a viewport pane to the right (default width: two-thirds).", + set_viewport_pane_width_help: "Set the viewport width of the column containing a pane.", + undo_layout_help: "Undo the latest structural layout change.", + create_viewport_pane_operation: "create viewport pane", + undo_layout_operation: "undo layout", + resize_exact_split_operation: "resize exact pane split", + split_id_subject: "split id", + resize_viewport_pane_operation: "resize viewport pane", + viewport_pane_subject: "viewport pane", + remote_viewport_panes_unsupported: "remote cmux server does not support viewport panes; upgrade the server before using new-pane-right", + ratio_must_be_number: "--ratio must be a number", + ratio_must_be_finite: "--ratio must be a finite number", + viewport_width_must_be_number: "--width must be a number", + viewport_width_must_be_finite: "--width must be a finite number", + viewport_width_out_of_range: "viewport pane width must be between 0.1 and 1.0", + surface_size_release_failed: "surface {surface} size release failed; retrying on the next layout: {error}", + pane_without_resizable_column: "pane {pane} has no resizable viewport column", + remote_viewport_resize_unsupported: "remote cmux server does not support viewport pane resizing; upgrade the server", + remote_layout_undo_unsupported: "remote cmux server does not support layout undo; upgrade the server", + layout_undo_missing_screen: "layout undo response is missing screen", + layout_undo_missing_revision: "layout undo response is missing revision", + layout_undo_missing_closes_panes: "layout undo response is missing closes_panes", + layout_undo_invalid_pane: "layout undo response contains an invalid pane", + layout_undo_missing_outcome: "layout undo response does not contain exactly one valid outcome", + layout_undo_confirmation_flags_together: "--revision and --confirm-close must be supplied together", + layout_changed_before_undo: "layout changed before undo", + unknown_split: "unknown split {split}", + unknown_pane_split: "unknown pane/split {pane}", + unrepresentable_viewport_width: "split {split} ratio {ratio} implies viewport width {width}; width must be between 0.1 and 1", + unrepresentable_viewport_ratio: "split {split} ratio {ratio} cannot be represented as a viewport width between 0.1 and 1", + viewport_ratio_target_missing: "the pane or split no longer exists", + viewport_ratio_out_of_range: "the requested ratio cannot be represented by a viewport width between 0.1 and 1", + viewport_column_missing: "the pane has no resizable viewport column", + unsupported_server_command: "{command} is not supported by this server", + layout_undo_applied: "undone screen={screen} revision={revision}", + layout_undo_confirmation_required: "confirmation required: rerun with --revision {revision} --confirm-close (closes panes {panes})", + }, + runtime: RuntimeMessages { + unknown_panic: "unknown panic", + renderer_panicked: "terminal renderer panicked: {message}", + host_input_failed: "host terminal input failed: {error}", + terminal_restore_also_failed: "{error}; host terminal restoration also failed: {restore_error}", + }, config: ConfigMessages { invalid_macos_option_as_alt: "cmux-tui: ignoring non-boolean keys.macos_option_as_alt = {value}", }, @@ -475,7 +646,10 @@ edits shell files. Authenticate with the configured host before retrying. action_open_private_workspace_port: "Open private workspace port", action_workspace_port: "Port", confirm_destructive_action: "Type CONFIRM to continue", + confirm_layout_undo: "Type CONFIRM to close pane(s) {items}", confirmation_mismatch: "Type CONFIRM exactly to run this action", + layout_nothing_to_undo: "Nothing to undo", + layout_undo_stale: "The layout changed; undo was not applied", initial_machine_connection_failed: "Could not connect", provider_notice_identity_unavailable: "Could not prepare the connection. Try again; if the problem persists, restart cmux.", provider_connection_already_running: "Another connection is already running. Close it and try again.", @@ -593,6 +767,52 @@ cmux machine-agent - ローカルの cmux セッションをリモートサー close_button: "Esc 閉じる", footer: "↑/↓ またはホイールでスクロール · Esc または ? で閉じる", }, + layout: LayoutMessages { + startup_shortcuts: " g 右に 2/3 幅の列を追加 U レイアウトを元に戻す", + verb_help_heading: "コマンドヘルプ", + new_pane_right_help: "右側にビューポートペインを作成(既定の幅: 3 分の 2)。", + set_viewport_pane_width_help: "ペインを含むビューポート列の幅を設定。", + undo_layout_help: "直前のレイアウト変更を元に戻す。", + create_viewport_pane_operation: "ビューポートペインを作成", + undo_layout_operation: "レイアウトを元に戻す", + resize_exact_split_operation: "ペイン分割のサイズを変更", + split_id_subject: "分割 ID", + resize_viewport_pane_operation: "ビューポートペインのサイズを変更", + viewport_pane_subject: "ビューポートペイン", + remote_viewport_panes_unsupported: "リモート cmux サーバーはビューポートペインに対応していません。new-pane-right を使用する前にサーバーをアップグレードしてください", + ratio_must_be_number: "--ratio には数値を指定してください", + ratio_must_be_finite: "--ratio には有限の数値を指定してください", + viewport_width_must_be_number: "--width には数値を指定してください", + viewport_width_must_be_finite: "--width には有限の数値を指定してください", + viewport_width_out_of_range: "ビューポートペインの幅は 0.1 から 1.0 の範囲で指定してください", + surface_size_release_failed: "サーフェス {surface} のサイズ設定の解放に失敗しました。次回のレイアウト更新時に再試行します: {error}", + pane_without_resizable_column: "ペイン {pane} にはサイズ変更可能なビューポート列がありません", + remote_viewport_resize_unsupported: "リモート cmux サーバーはビューポートペインのサイズ変更に対応していません。サーバーをアップグレードしてください", + remote_layout_undo_unsupported: "リモート cmux サーバーはレイアウトの取り消しに対応していません。サーバーをアップグレードしてください", + layout_undo_missing_screen: "レイアウト取り消し応答にスクリーンがありません", + layout_undo_missing_revision: "レイアウト取り消し応答にリビジョンがありません", + layout_undo_missing_closes_panes: "レイアウト取り消し応答に closes_panes がありません", + layout_undo_invalid_pane: "レイアウト取り消し応答に無効なペインがあります", + layout_undo_missing_outcome: "レイアウト取り消し応答に有効な結果が1つだけ含まれていません", + layout_undo_confirmation_flags_together: "--revision と --confirm-close は同時に指定してください", + layout_changed_before_undo: "取り消し前にレイアウトが変更されました", + unknown_split: "分割 {split} が見つかりません", + unknown_pane_split: "ペインまたは分割 {pane} が見つかりません", + unrepresentable_viewport_width: "分割 {split} の比率 {ratio} ではビューポート幅が {width} になります。幅は 0.1 から 1 の範囲で指定してください", + unrepresentable_viewport_ratio: "分割 {split} の比率 {ratio} は 0.1 から 1 の範囲のビューポート幅では表現できません", + viewport_ratio_target_missing: "対象のペインまたは分割が存在しません", + viewport_ratio_out_of_range: "指定した比率は 0.1 から 1 の範囲のビューポート幅では表現できません", + viewport_column_missing: "対象のペインにはサイズ変更可能なビューポート列がありません", + unsupported_server_command: "{command} はこのサーバーではサポートされていません", + layout_undo_applied: "元に戻しました screen={screen} revision={revision}", + layout_undo_confirmation_required: "確認が必要です: --revision {revision} --confirm-close を付けて再実行してください(閉じるペイン: {panes})", + }, + runtime: RuntimeMessages { + unknown_panic: "不明なパニック", + renderer_panicked: "ターミナル描画処理でパニックが発生しました: {message}", + host_input_failed: "ホストターミナルの入力に失敗しました: {error}", + terminal_restore_also_failed: "{error}; ホストターミナルの復元にも失敗しました: {restore_error}", + }, config: ConfigMessages { invalid_macos_option_as_alt: "cmux-tui: 真偽値ではない keys.macos_option_as_alt = {value} を無視します", }, @@ -658,7 +878,10 @@ cmux machine-agent - ローカルの cmux セッションをリモートサー action_open_private_workspace_port: "非公開のワークスペースポートを開く", action_workspace_port: "ポート", confirm_destructive_action: "続行するには CONFIRM と入力", + confirm_layout_undo: "ペイン {items} を閉じるには CONFIRM と入力", confirmation_mismatch: "この操作を実行するには CONFIRM と正確に入力してください", + layout_nothing_to_undo: "元に戻せるレイアウト操作はありません", + layout_undo_stale: "レイアウトが変更されたため、元に戻す操作は適用されませんでした", initial_machine_connection_failed: "マシンに接続できませんでした", provider_notice_identity_unavailable: "接続を準備できませんでした。もう一度お試しください。問題が解決しない場合は、cmux を再起動してください。", provider_connection_already_running: "別の接続がすでに実行中です。終了してから、もう一度お試しください。", @@ -871,6 +1094,85 @@ mod tests { catalog_for_locale("ja_JP.UTF-8").sidebar.machine_managed_authority_invalid, "マシンプロバイダーから無効な管理ワークスペース権限バインディングが返されました" ); + assert_eq!( + catalog_for_locale("en_US.UTF-8").sidebar.confirm_layout_undo, + "Type CONFIRM to close pane(s) {items}" + ); + assert_eq!( + catalog_for_locale("ja_JP.UTF-8").sidebar.confirm_layout_undo, + "ペイン {items} を閉じるには CONFIRM と入力" + ); + assert_eq!( + catalog_for_locale("ja_JP.UTF-8").sidebar.layout_nothing_to_undo, + "元に戻せるレイアウト操作はありません" + ); + assert_eq!( + catalog_for_locale("ja_JP.UTF-8").sidebar.layout_undo_stale, + "レイアウトが変更されたため、元に戻す操作は適用されませんでした" + ); + let japanese_layout = &catalog_for_locale("ja_JP.UTF-8").layout; + assert_eq!( + catalog_for_locale("en_US.UTF-8").layout.surface_size_release_failed(7, "disconnected"), + "surface 7 size release failed; retrying on the next layout: disconnected" + ); + assert_eq!( + japanese_layout.surface_size_release_failed(7, "切断"), + "サーフェス 7 のサイズ設定の解放に失敗しました。次回のレイアウト更新時に再試行します: 切断" + ); + assert_eq!( + japanese_layout.viewport_width_out_of_range, + "ビューポートペインの幅は 0.1 から 1.0 の範囲で指定してください" + ); + assert_eq!( + japanese_layout.viewport_width_must_be_finite, + "--width には有限の数値を指定してください" + ); + assert_eq!( + japanese_layout.viewport_width_must_be_number, + "--width には数値を指定してください" + ); + assert_eq!(japanese_layout.ratio_must_be_number, "--ratio には数値を指定してください"); + assert_eq!( + japanese_layout.ratio_must_be_finite, + "--ratio には有限の数値を指定してください" + ); + assert_eq!( + japanese_layout.pane_without_resizable_column(42), + "ペイン 42 にはサイズ変更可能なビューポート列がありません" + ); + assert_eq!( + japanese_layout.unsupported_server_command("undo-layout"), + "undo-layout はこのサーバーではサポートされていません" + ); + assert_eq!(japanese_layout.layout_undo_applied(3, 9), "元に戻しました screen=3 revision=9"); + assert_eq!( + japanese_layout.layout_undo_confirmation_required(8, "15,16"), + "確認が必要です: --revision 8 --confirm-close を付けて再実行してください(閉じるペイン: 15,16)" + ); + assert_eq!( + japanese_layout.layout_undo_confirmation_flags_together, + "--revision と --confirm-close は同時に指定してください" + ); + assert_eq!( + catalog_for_locale("ja_JP.UTF-8").runtime.renderer_panicked("描画セルが無効"), + "ターミナル描画処理でパニックが発生しました: 描画セルが無効" + ); + assert_eq!( + catalog_for_locale("ja_JP.UTF-8").runtime.host_input_failed("切断"), + "ホストターミナルの入力に失敗しました: 切断" + ); + assert_eq!( + catalog_for_locale("en_US.UTF-8") + .runtime + .terminal_restore_also_failed("event loop failed", "restore failed"), + "event loop failed; host terminal restoration also failed: restore failed" + ); + assert_eq!( + catalog_for_locale("ja_JP.UTF-8") + .runtime + .terminal_restore_also_failed("イベントループ失敗", "復元失敗"), + "イベントループ失敗; ホストターミナルの復元にも失敗しました: 復元失敗" + ); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 3d22bd38529d..7c418b8882c0 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -12,6 +12,7 @@ mod cli; mod config; mod host_colors; mod keys; +mod layout_undo; mod localization; mod machine; #[cfg(unix)] @@ -290,8 +291,8 @@ KEYS (prefix: Ctrl-b) w next workspace alias Alt-{ / Alt-} prev/next workspace z maximize/restore pane s show/hide sidebar m compact/full sidebar - e toggle sidebar view S focus sidebar - ? keyboard shortcuts + e toggle sidebar view S focus sidebar ? keyboard shortcuts + {layout_shortcuts} < browser back > browser forward r/u browser reload/edit URL Ctrl-b send a literal Ctrl-b @@ -311,7 +312,9 @@ CLI VERBS reload-config, set-window-title, clear-window-title, list-workspaces, export-layout, apply-layout, send, read-screen, read-scrollback, clear-history, vt-state, new-tab, new-browser-tab, new-workspace, - new-screen, new-pane, split, set-ratio, set-split-ratio, pane-neighbor, focus-direction, + new-screen, new-pane, new-pane-right, split, set-ratio, set-split-ratio, + set-viewport-pane-width, undo-layout, + pane-neighbor, focus-direction, swap-pane, zoom-pane, process-info, set-default-colors, close-surface, close-pane, close-screen, close-workspace, rename-pane, rename-surface, rename-screen, rename-workspace, @@ -330,23 +333,21 @@ PLUGIN VERBS (local; no socket protocol command) plugin remove "; -fn usage_for(messages: &localization::MachineAgentMessages) -> String { - usage_for_platform(messages, cfg!(unix)) +fn usage_for(catalog: &localization::Catalog) -> String { + usage_for_platform(catalog, cfg!(unix)) } -fn usage_for_platform( - messages: &localization::MachineAgentMessages, - supports_machine_agent: bool, -) -> String { - if supports_machine_agent { - USAGE.replace(" {machine_agent_usage}\n", &format!(" {}\n", messages.usage)) +fn usage_for_platform(catalog: &localization::Catalog, supports_machine_agent: bool) -> String { + let usage = if supports_machine_agent { + USAGE.replace(" {machine_agent_usage}\n", &format!(" {}\n", catalog.machine_agent.usage)) } else { USAGE.replace(" {machine_agent_usage}\n", "") - } + }; + usage.replace(" {layout_shortcuts}\n", &format!("{}\n", catalog.layout.startup_shortcuts)) } fn usage() -> String { - usage_for(&localization::catalog().machine_agent) + usage_for(localization::catalog()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1687,16 +1688,10 @@ mod tests { #[test] fn startup_help_localizes_the_machine_agent_entrypoint() { - let english = usage_for_platform( - &localization::catalog_for_locale("en_US.UTF-8").machine_agent, - true, - ); + let english = usage_for_platform(localization::catalog_for_locale("en_US.UTF-8"), true); assert!(english.contains("cmux machine-agent")); assert!(english.contains("Share one local session through the configured host")); - let japanese = usage_for_platform( - &localization::catalog_for_locale("ja_JP.UTF-8").machine_agent, - true, - ); + let japanese = usage_for_platform(localization::catalog_for_locale("ja_JP.UTF-8"), true); assert!(japanese.contains("cmux machine-agent")); assert!(japanese.contains("設定したホスト経由でローカルセッションを共有")); assert!(!japanese.contains("Share one local session")); @@ -1704,7 +1699,7 @@ mod tests { #[test] fn startup_help_omits_machine_agent_on_unsupported_platforms() { - let english = &localization::catalog_for_locale("en_US.UTF-8").machine_agent; + let english = localization::catalog_for_locale("en_US.UTF-8"); let usage = usage_for_platform(english, false); assert!(!usage.contains("machine-agent")); assert!(usage.contains("cmux-tui relay")); @@ -1721,6 +1716,19 @@ mod tests { assert!(parse_args_result(["attach".into(), "--surface".into()]).is_err()); } + #[test] + fn startup_help_lists_column_action() { + let english = usage_for_platform(localization::catalog_for_locale("en_US.UTF-8"), true); + assert!(english.contains("g new 2/3 column right")); + assert!(english.contains("new-pane-right")); + assert!(english.contains("U undo layout")); + assert!(english.contains("undo-layout")); + + let japanese = usage_for_platform(localization::catalog_for_locale("ja_JP.UTF-8"), true); + assert!(japanese.contains("g 右に 2/3 幅の列を追加")); + assert!(japanese.contains("U レイアウトを元に戻す")); + } + #[test] fn provider_mode_rejects_server_and_attach_options_before_connecting() { let parsed = args(&[ diff --git a/cmux-tui/crates/cmux-tui/src/pty_input.rs b/cmux-tui/crates/cmux-tui/src/pty_input.rs index 5a2f696e9aab..c4ae76ff2d39 100644 --- a/cmux-tui/crates/cmux-tui/src/pty_input.rs +++ b/cmux-tui/crates/cmux-tui/src/pty_input.rs @@ -17,7 +17,7 @@ use smallvec::SmallVec; use crate::session::{SurfaceHandle, is_remote_timeout, is_remote_transport_failure}; -const QUEUE_CAPACITY: usize = 512; +pub(crate) const PTY_OPERATION_QUEUE_CAPACITY: usize = 512; const MAX_QUEUED_BYTES: usize = 4 * 1024 * 1024; const MAX_CONCURRENT_SURFACE_OPERATIONS: usize = 32; const RESERVED_RELEASE_BYTES: usize = 64; @@ -512,7 +512,7 @@ impl PtyInputSender { let reserves_release = event.kind == PtyInputKind::Press; let active_operations = state.in_flight_surface_operations.len(); let active_bytes = state.in_flight_surface_operations.values().copied().sum::(); - let available_capacity = QUEUE_CAPACITY.saturating_sub(active_operations); + let available_capacity = PTY_OPERATION_QUEUE_CAPACITY.saturating_sub(active_operations); let available_bytes = MAX_QUEUED_BYTES.saturating_sub(active_bytes); let QueueState { events, queued_bytes, release_reservations, .. } = &mut *state; let outcome = enqueue_bounded_with_evictions( @@ -1440,7 +1440,7 @@ mod tests { sender.enqueue(event(7, 1, PtyInputKind::Motion)), PtyInputEnqueueResult::Accepted ); - for _ in 0..QUEUE_CAPACITY - 1 { + for _ in 0..PTY_OPERATION_QUEUE_CAPACITY - 1 { assert_eq!( sender.enqueue(PtyInputEvent::mutation("fill", None, false, || Ok(()))), PtyInputEnqueueResult::Accepted diff --git a/cmux-tui/crates/cmux-tui/src/session/mod.rs b/cmux-tui/crates/cmux-tui/src/session/mod.rs index 59d14ce0e88f..e812ccd645de 100644 --- a/cmux-tui/crates/cmux-tui/src/session/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/session/mod.rs @@ -13,11 +13,15 @@ pub(crate) mod tree; use std::sync::Arc; use std::sync::atomic::Ordering; -use cmux_tui_core::server::PROVIDER_MANAGED_WORKSPACE_GUARD_CAPABILITY; +use cmux_tui_core::server::{ + LAYOUT_UNDO_CAPABILITY, PROVIDER_MANAGED_WORKSPACE_GUARD_CAPABILITY, + VIEWPORT_COLUMN_RESIZE_CAPABILITY, VIEWPORT_SPLITS_CAPABILITY, +}; use cmux_tui_core::{ - BrowserFrame, BrowserStatus, ClearHistoryFailure, DefaultColors, Mux, MuxEventReceiver, PaneId, - ScreenId, SidebarPluginStatus, SplitDir, SplitId, Surface, SurfaceId, SurfaceKind, - SurfaceRenderFrame, SurfaceResizeReporter, WorkspaceId, ZoomMode, + BrowserFrame, BrowserStatus, ClearHistoryFailure, DefaultColors, LayoutRatioError, + LayoutUndoError, LayoutUndoResult, Mux, MuxEventReceiver, PaneId, ScreenId, + SidebarPluginStatus, SplitDir, SplitId, Surface, SurfaceId, SurfaceKind, SurfaceRenderFrame, + SurfaceResizeReporter, ViewportWidthError, WorkspaceId, ZoomMode, }; use ghostty_vt::{KeyInput, MouseInput, RenderState, Terminal}; use serde::Deserialize; @@ -50,13 +54,97 @@ pub(crate) fn is_remote_timeout(error: &anyhow::Error) -> bool { } pub(crate) fn is_remote_surface_unavailable(error: &anyhow::Error, surface: SurfaceId) -> bool { - error.downcast_ref::().is_some_and(|error| { - matches!( - error, - remote::RemoteRequestError::Rejected { error: message, .. } - if message == &format!("unknown surface {surface}") + let expected = format!("unknown surface {surface}"); + error + .downcast_ref::() + .is_some_and(|error| error.rejection_message() == Some(expected.as_str())) +} + +fn normalize_remote_layout_undo_error(error: anyhow::Error) -> anyhow::Error { + let Some(remote) = error.downcast_ref::() else { + return error; + }; + match remote.rejection_code() { + Some(LayoutUndoError::UNAVAILABLE_CODE) => LayoutUndoError::Unavailable.into(), + Some(LayoutUndoError::STALE_CODE) => LayoutUndoError::Stale( + remote + .rejection_message() + .unwrap_or(crate::localization::catalog().layout.layout_changed_before_undo) + .to_string(), ) - }) + .into(), + _ => error, + } +} + +fn localized_layout_ratio_error(error: LayoutRatioError) -> anyhow::Error { + let messages = &crate::localization::catalog().layout; + let message = match error { + LayoutRatioError::UnknownPaneSplit { pane } => messages.unknown_pane_split(pane), + LayoutRatioError::UnknownSplit { split } => messages.unknown_split(split), + LayoutRatioError::UnrepresentableViewportWidth { split, ratio, width } => { + messages.unrepresentable_viewport_width(split, ratio, width) + } + }; + anyhow::anyhow!(message) +} + +fn normalize_remote_split_ratio_error( + error: anyhow::Error, + split: SplitId, + ratio: f32, +) -> anyhow::Error { + let Some(remote) = error.downcast_ref::() else { + return error; + }; + let messages = &crate::localization::catalog().layout; + match remote.rejection_code() { + Some(LayoutRatioError::UNKNOWN_TARGET_CODE) => { + anyhow::anyhow!(messages.unknown_split(split)) + } + Some(LayoutRatioError::OUT_OF_RANGE_CODE) => { + anyhow::anyhow!(messages.unrepresentable_viewport_ratio(split, ratio)) + } + _ => error, + } +} + +fn validate_viewport_width(width: f32) -> anyhow::Result<()> { + if width.is_finite() + && (cmux_tui_core::MIN_VIEWPORT_PANE_WIDTH..=cmux_tui_core::MAX_VIEWPORT_PANE_WIDTH) + .contains(&width) + { + return Ok(()); + } + anyhow::bail!(crate::localization::catalog().layout.viewport_width_out_of_range) +} + +fn localized_viewport_width_error(error: ViewportWidthError) -> anyhow::Error { + let messages = &crate::localization::catalog().layout; + match error { + ViewportWidthError::OutOfRange { .. } => { + anyhow::anyhow!(messages.viewport_width_out_of_range) + } + ViewportWidthError::PaneNotResizable { pane } => { + anyhow::anyhow!(messages.pane_without_resizable_column(pane)) + } + } +} + +fn normalize_remote_viewport_width_error(error: anyhow::Error, pane: PaneId) -> anyhow::Error { + let Some(remote) = error.downcast_ref::() else { + return error; + }; + let messages = &crate::localization::catalog().layout; + match remote.rejection_code() { + Some(ViewportWidthError::OUT_OF_RANGE_CODE) => { + anyhow::anyhow!(messages.viewport_width_out_of_range) + } + Some(ViewportWidthError::COLUMN_MISSING_CODE) => { + anyhow::anyhow!(messages.pane_without_resizable_column(pane)) + } + _ => error, + } } #[cfg(test)] @@ -80,7 +168,18 @@ pub(crate) fn test_remote_rejected_error() -> anyhow::Error { #[cfg(test)] pub(crate) fn test_remote_rejected_error_with_message(message: &str) -> anyhow::Error { - remote::RemoteRequestError::Rejected { error: message.to_string(), delivery: None }.into() + remote::RemoteRequestError::Rejected { error: message.to_string(), code: None, delivery: None } + .into() +} + +#[cfg(test)] +fn test_remote_rejected_error_with_code(message: &str, code: &str) -> anyhow::Error { + remote::RemoteRequestError::Rejected { + error: message.to_string(), + code: Some(code.to_string()), + delivery: None, + } + .into() } pub struct SidebarPluginSurface { @@ -152,6 +251,15 @@ pub enum SurfaceHandle { } impl Session { + pub(crate) fn allocate_layout_resize_owner(&self) -> u64 { + match self { + Session::Local(mux) => mux.allocate_in_process_resize_owner(), + // Remote layout transactions are scoped by the server-assigned + // control-client ID, so this local value is never serialized. + Session::Remote(_) => 0, + } + } + pub fn clients(&self) -> anyhow::Result> { let value = match self { Session::Local(mux) => mux.control_clients_json(0), @@ -762,15 +870,154 @@ impl Session { } } + pub fn new_pane_right( + &self, + pane: PaneId, + width: f32, + size: Option<(u16, u16)>, + ) -> anyhow::Result { + validate_viewport_width(width)?; + match self { + Session::Local(mux) => mux.new_pane_right(pane, width, size).map(|surface| surface.id), + Session::Remote(remote) => { + if !remote.supports_capability(VIEWPORT_SPLITS_CAPABILITY) { + anyhow::bail!( + crate::localization::catalog().layout.remote_viewport_panes_unsupported + ); + } + let result = remote.request(with_size( + json!({"cmd": "new-pane-right", "pane": pane, "width": width}), + size, + ))?; + response_surface(&result, "pane") + } + } + } + + #[cfg(test)] pub fn set_split_ratio(&self, split: SplitId, ratio: f32) -> anyhow::Result<()> { + self.set_split_ratio_inner(split, ratio, None) + } + + pub fn set_split_ratio_in_transaction( + &self, + split: SplitId, + ratio: f32, + owner: u64, + transaction: u64, + ) -> anyhow::Result<()> { + self.set_split_ratio_inner(split, ratio, Some((owner, transaction))) + } + + fn set_split_ratio_inner( + &self, + split: SplitId, + ratio: f32, + transaction: Option<(u64, u64)>, + ) -> anyhow::Result<()> { match self { - Session::Local(mux) => mux - .set_split_ratio(split, ratio) - .then_some(()) - .ok_or_else(|| anyhow::anyhow!("unknown split {split}")), + Session::Local(mux) => transaction + .map_or_else( + || mux.set_split_ratio_checked(split, ratio), + |(owner, transaction)| { + mux.set_split_ratio_in_process_transaction_checked( + split, + ratio, + owner, + transaction, + ) + }, + ) + .map_err(localized_layout_ratio_error), Session::Remote(remote) => remote - .request(json!({"cmd": "set-split-ratio", "split": split, "ratio": ratio})) - .map(|_| ()), + .request(json!({ + "cmd": "set-split-ratio", + "split": split, + "ratio": ratio, + "transaction": transaction.map(|(_, transaction)| transaction), + })) + .map(|_| ()) + .map_err(|error| normalize_remote_split_ratio_error(error, split, ratio)), + } + } + + pub fn set_viewport_pane_width_in_transaction( + &self, + pane: PaneId, + width: f32, + owner: u64, + transaction: u64, + ) -> anyhow::Result<()> { + self.set_viewport_pane_width_inner(pane, width, Some((owner, transaction))) + } + + fn set_viewport_pane_width_inner( + &self, + pane: PaneId, + width: f32, + transaction: Option<(u64, u64)>, + ) -> anyhow::Result<()> { + validate_viewport_width(width)?; + match self { + Session::Local(mux) => transaction + .map_or_else( + || mux.set_viewport_pane_width_checked(pane, width), + |(owner, transaction)| { + mux.set_viewport_pane_width_in_process_transaction_checked( + pane, + width, + owner, + transaction, + ) + }, + ) + .map_err(localized_viewport_width_error), + Session::Remote(remote) => { + if !remote.supports_capability(VIEWPORT_COLUMN_RESIZE_CAPABILITY) { + anyhow::bail!( + crate::localization::catalog().layout.remote_viewport_resize_unsupported + ); + } + remote + .request(json!({ + "cmd": "set-viewport-pane-width", + "pane": pane, + "width": width, + "transaction": transaction.map(|(_, transaction)| transaction), + })) + .map(|_| ()) + .map_err(|error| normalize_remote_viewport_width_error(error, pane)) + } + } + } + + pub fn undo_layout( + &self, + pane: PaneId, + revision: Option, + confirm_close: bool, + ) -> anyhow::Result { + match self { + Session::Local(mux) => mux.undo_layout(pane, revision, confirm_close), + Session::Remote(remote) => { + if !remote.supports_capability(LAYOUT_UNDO_CAPABILITY) { + anyhow::bail!( + crate::localization::catalog().layout.remote_layout_undo_unsupported + ); + } + let result = remote + .request(json!({ + "cmd": "undo-layout", + "pane": pane, + "revision": revision, + "confirm_close": confirm_close, + })) + .map_err(normalize_remote_layout_undo_error)?; + crate::layout_undo::decode_layout_undo_result( + &result, + &crate::localization::catalog().layout, + ) + } } } @@ -1328,9 +1575,9 @@ impl SurfaceHandle { } } - pub fn browser_frame(&self) -> Option { + pub fn browser_frame(&self) -> Option> { match self { - SurfaceHandle::Local(surface, _) => surface.browser_frame(), + SurfaceHandle::Local(surface, _) => surface.browser_frame_shared(), SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Browser => { surface.browser_frame() } @@ -1338,6 +1585,16 @@ impl SurfaceHandle { } } + pub fn has_browser_frame(&self) -> bool { + match self { + SurfaceHandle::Local(surface, _) => surface.has_browser_frame(), + SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Browser => { + surface.has_browser_frame() + } + SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => false, + } + } + pub fn browser_url(&self) -> Option { match self { SurfaceHandle::Local(surface, _) => surface.browser_url(), @@ -1559,11 +1816,12 @@ pub(crate) fn test_remote_session_with_blocked_attach_transport_failure( #[cfg(test)] mod tests { - use cmux_tui_core::{Mux, SurfaceOptions}; + use cmux_tui_core::{LayoutUndoError, Mux, SurfaceOptions}; use super::{ - Session, is_remote_surface_unavailable, resize_action, - test_remote_rejected_error_with_message, test_remote_transport_error, + Session, is_remote_surface_unavailable, normalize_remote_layout_undo_error, resize_action, + test_remote_rejected_error_with_code, test_remote_rejected_error_with_message, + test_remote_transport_error, }; #[test] @@ -1579,6 +1837,27 @@ mod tests { assert!(!is_remote_surface_unavailable(&test_remote_transport_error(), 77)); } + #[test] + fn remote_layout_undo_error_codes_restore_typed_failures() { + let stale = normalize_remote_layout_undo_error(test_remote_rejected_error_with_code( + "layout changed", + LayoutUndoError::STALE_CODE, + )); + assert!(matches!( + stale.downcast_ref::(), + Some(LayoutUndoError::Stale(message)) if message == "layout changed" + )); + + let unavailable = normalize_remote_layout_undo_error(test_remote_rejected_error_with_code( + "no layout change to undo", + LayoutUndoError::UNAVAILABLE_CODE, + )); + assert!(matches!( + unavailable.downcast_ref::(), + Some(LayoutUndoError::Unavailable) + )); + } + #[test] fn first_layout_after_attach_sends_ordered_resize() { let desired = (123, 65); diff --git a/cmux-tui/crates/cmux-tui/src/session/remote.rs b/cmux-tui/crates/cmux-tui/src/session/remote.rs index 8186b45206cf..38fb78bdb38b 100644 --- a/cmux-tui/crates/cmux-tui/src/session/remote.rs +++ b/cmux-tui/crates/cmux-tui/src/session/remote.rs @@ -12,6 +12,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use base64::Engine; +use cmux_tui_core::server::{VIEWPORT_COLUMN_RESIZE_CAPABILITY, VIEWPORT_SPLITS_CAPABILITY}; use cmux_tui_core::{ BrowserFrame, BrowserSource, BrowserStatus, ClearHistoryDelivery, ClearHistoryFailure, DefaultColors, MuxEvent, MuxEventBroadcaster, MuxEventReceiver, NotificationEvent, @@ -28,7 +29,9 @@ use serde_json::{Value, json}; use zeroize::Zeroize; use super::CLEAR_HISTORY_UNSUPPORTED_ERROR; -use super::tree::{TreeView, parse_tree}; +#[cfg(test)] +use super::tree::parse_tree; +use super::tree::{TreeCapabilities, TreeView, parse_tree_with_capabilities}; const SUPPORTED_PROTOCOL_VERSION: u64 = 10; const SURFACE_OVERFLOW_RETRY_DELAYS: [Duration; 3] = @@ -95,7 +98,7 @@ pub(crate) enum RemoteRequestError { Encode(serde_json::Error), Transport(io::Error), Timeout, - Rejected { error: String, delivery: Option }, + Rejected { error: String, code: Option, delivery: Option }, Shutdown, } @@ -107,6 +110,20 @@ impl RemoteRequestError { pub(crate) fn is_timeout(&self) -> bool { matches!(self, Self::Timeout) } + + pub(crate) fn rejection_code(&self) -> Option<&str> { + match self { + Self::Rejected { code, .. } => code.as_deref(), + _ => None, + } + } + + pub(crate) fn rejection_message(&self) -> Option<&str> { + match self { + Self::Rejected { error, .. } => Some(error), + _ => None, + } + } } impl std::fmt::Display for RemoteRequestError { @@ -124,7 +141,7 @@ impl std::fmt::Display for RemoteRequestError { impl std::error::Error for RemoteRequestError {} #[derive(Clone)] struct RemoteBrowserFrame { - frame: BrowserFrame, + frame: Arc, } #[derive(Clone)] @@ -371,7 +388,7 @@ impl RemoteSurface { *self.reported_size.lock().unwrap() = None; } - pub fn browser_frame(&self) -> Option { + pub fn browser_frame(&self) -> Option> { let browser = self.browser.lock().unwrap(); if matches!(browser.status, BrowserStatus::Failed(_)) { None @@ -380,6 +397,11 @@ impl RemoteSurface { } } + pub fn has_browser_frame(&self) -> bool { + let browser = self.browser.lock().unwrap(); + !matches!(browser.status, BrowserStatus::Failed(_)) && browser.frame.is_some() + } + pub fn browser_url(&self) -> Option { self.browser.lock().unwrap().url.clone() } @@ -1241,12 +1263,13 @@ impl RemoteSession { Ok(response.get("data").cloned().unwrap_or(Value::Null)) } else { let error = response.get("error").and_then(|v| v.as_str()).unwrap_or("unknown error"); + let code = response.get("error_code").and_then(Value::as_str).map(ToString::to_string); let delivery = match response.get("error_delivery").and_then(Value::as_str) { Some("known-not-delivered") => Some(ClearHistoryDelivery::KnownNotDelivered), Some("ambiguous") => Some(ClearHistoryDelivery::Ambiguous), _ => None, }; - Err(RemoteRequestError::Rejected { error: error.to_string(), delivery }.into()) + Err(RemoteRequestError::Rejected { error: error.to_string(), code, delivery }.into()) } } @@ -1567,7 +1590,15 @@ impl RemoteSession { return Err(e); } }; - let tree = parse_tree(&data); + let capabilities = self.capabilities.lock().unwrap(); + let tree = parse_tree_with_capabilities( + &data, + TreeCapabilities { + viewport_splits: capabilities.contains(VIEWPORT_SPLITS_CAPABILITY), + viewport_column_resize: capabilities.contains(VIEWPORT_COLUMN_RESIZE_CAPABILITY), + }, + ); + drop(capabilities); self.exited_surfaces.lock().unwrap().retain(|surface_id| { tree.workspaces .iter() @@ -1787,16 +1818,38 @@ fn hex_color(color: Rgb) -> String { fn parse_browser_frame(value: &Value) -> Option { let data_b64 = value.get("data")?.as_str()?.to_string(); let seq = value.get("seq")?.as_u64()?; - let width = value.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - let height = value.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let width = value + .get("width") + .and_then(Value::as_u64) + .and_then(|width| u32::try_from(width).ok()) + .unwrap_or(0); + let height = value + .get("height") + .and_then(Value::as_u64) + .and_then(|height| u32::try_from(height).ok()) + .unwrap_or(0); + let image_width = value + .get("image_width") + .and_then(Value::as_u64) + .and_then(|width| u32::try_from(width).ok()) + .filter(|width| *width > 0) + .unwrap_or(width); + let image_height = value + .get("image_height") + .and_then(Value::as_u64) + .and_then(|height| u32::try_from(height).ok()) + .filter(|height| *height > 0) + .unwrap_or(height); Some(RemoteBrowserFrame { - frame: BrowserFrame { + frame: Arc::new(BrowserFrame { session_id: String::new(), data_b64, css_width: width, css_height: height, + image_width, + image_height, seq, - }, + }), }) } @@ -1966,6 +2019,32 @@ mod tests { validate_remote_identity(&json!({"app": "cmux-tui", "protocol": 10})).unwrap(); } + #[test] + fn browser_frame_parses_image_dimensions_with_legacy_fallback() { + let frame = parse_browser_frame(&json!({ + "seq": 1, + "width": 800, + "height": 600, + "image_width": 400, + "image_height": 300, + "data": "frame", + })) + .unwrap() + .frame; + assert_eq!((frame.css_width, frame.css_height), (800, 600)); + assert_eq!((frame.image_width, frame.image_height), (400, 300)); + + let legacy = parse_browser_frame(&json!({ + "seq": 2, + "width": 320, + "height": 200, + "data": "legacy", + })) + .unwrap() + .frame; + assert_eq!((legacy.image_width, legacy.image_height), (320, 200)); + } + #[test] fn resolved_cursor_colors_force_the_active_screen_across_alt_screen_modes() { for mode in [47, 1047, 1049] { @@ -3576,6 +3655,7 @@ mod tests { fn subscription_recovery_retries_only_explicit_rejection() { let rejected = anyhow::Error::new(RemoteRequestError::Rejected { error: "no capacity".to_string(), + code: None, delivery: None, }); let timeout = anyhow::Error::new(RemoteRequestError::Timeout); diff --git a/cmux-tui/crates/cmux-tui/src/session/tree.rs b/cmux-tui/crates/cmux-tui/src/session/tree.rs index 2cae07658920..2c2963f90c89 100644 --- a/cmux-tui/crates/cmux-tui/src/session/tree.rs +++ b/cmux-tui/crates/cmux-tui/src/session/tree.rs @@ -1,11 +1,12 @@ //! Read-only tree snapshots shared by the renderer and input handling, //! plus the JSON parser for the remote `list-workspaces` shape. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use cmux_tui_core::{ - BrowserSource, Node, PaneId, ScreenId, SplitDir, State, SurfaceId, SurfaceKind, - SurfaceNotification, WorkspaceId, assign_short_ids, + BrowserSource, MAX_VIEWPORT_PANE_WIDTH, MIN_VIEWPORT_PANE_WIDTH, Node, PaneId, ScreenId, + SplitDir, SplitId, State, SurfaceId, SurfaceKind, SurfaceNotification, WorkspaceId, + assign_short_ids, }; use serde_json::Value; @@ -42,6 +43,8 @@ pub struct ScreenView { pub layout: Node, pub active_pane: PaneId, pub zoomed_pane: Option, + pub viewport_base_width: Option, + pub viewport_splits: BTreeMap, pub panes: Vec, } @@ -210,7 +213,7 @@ impl WorkspaceView { impl ScreenView { pub fn pane(&self, id: PaneId) -> Option<&PaneView> { - self.panes.iter().find(|p| p.id == id) + self.panes.iter().find(|pane| pane.id == id) } /// Display name: the user-assigned name, else its zero-based position. @@ -319,6 +322,8 @@ pub fn tree_from_state_with_notifications( layout: screen.root.clone(), active_pane: screen.active_pane, zoomed_pane: screen.zoomed_pane, + viewport_base_width: screen.viewport_base_width, + viewport_splits: screen.viewport_splits.clone(), panes: pane_ids.iter().filter_map(pane_view).collect(), } }) @@ -424,7 +429,13 @@ fn parse_notification(value: &Value) -> Option { }) } -fn parse_screen(value: &Value) -> Option { +#[derive(Clone, Copy, Default)] +pub(super) struct TreeCapabilities { + pub viewport_splits: bool, + pub viewport_column_resize: bool, +} + +fn parse_screen(value: &Value, capabilities: TreeCapabilities) -> Option { Some(ScreenView { id: value.get("id")?.as_u64()?, short_id: value.get("short_id").and_then(|v| v.as_str()).unwrap_or_default().to_string(), @@ -432,6 +443,39 @@ fn parse_screen(value: &Value) -> Option { layout: value.get("layout").and_then(parse_layout)?, active_pane: value.get("active_pane").and_then(|v| v.as_u64()).unwrap_or(0), zoomed_pane: value.get("zoomed_pane").and_then(|v| v.as_u64()), + viewport_base_width: if capabilities.viewport_column_resize { + value + .get("viewport_base_width") + .and_then(Value::as_f64) + .filter(|width| { + (f64::from(MIN_VIEWPORT_PANE_WIDTH)..=f64::from(MAX_VIEWPORT_PANE_WIDTH)) + .contains(width) + }) + .map(|width| width as f32) + } else { + None + }, + viewport_splits: if capabilities.viewport_splits { + value + .get("viewport_splits") + .and_then(Value::as_array) + .map(|splits| { + splits + .iter() + .filter_map(|value| { + let split = value.get("split")?.as_u64()?; + let width = value.get("width")?.as_f64()?; + (f64::from(MIN_VIEWPORT_PANE_WIDTH) + ..=f64::from(MAX_VIEWPORT_PANE_WIDTH)) + .contains(&width) + .then_some((split, width as f32)) + }) + .collect() + }) + .unwrap_or_default() + } else { + BTreeMap::new() + }, panes: value .get("panes") .and_then(|v| v.as_array()) @@ -441,7 +485,15 @@ fn parse_screen(value: &Value) -> Option { } /// Parse the remote `list-workspaces` response. +#[cfg(test)] pub fn parse_tree(data: &Value) -> TreeView { + parse_tree_with_capabilities(data, TreeCapabilities::default()) +} + +pub(super) fn parse_tree_with_capabilities( + data: &Value, + capabilities: TreeCapabilities, +) -> TreeView { let mut tree = TreeView { workspace_revision: data .get("workspace_revision") @@ -470,7 +522,7 @@ pub fn parse_tree(data: &Value) -> TreeView { if screen.get("active").and_then(|v| v.as_bool()) == Some(true) { view.active_screen = s; } - if let Some(parsed) = parse_screen(screen) { + if let Some(parsed) = parse_screen(screen, capabilities) { view.screens.push(parsed); } } @@ -494,6 +546,8 @@ mod tests { layout: Node::Leaf(1), active_pane: 1, zoomed_pane: None, + viewport_base_width: None, + viewport_splits: BTreeMap::new(), panes: Vec::new(), }; @@ -503,32 +557,90 @@ mod tests { #[test] fn protocol_v8_parser_preserves_split_ids() { + let tree = parse_tree_with_capabilities( + &json!({ + "workspaces": [{ + "id": 1, + "name": "one", + "active": true, + "screens": [{ + "id": 2, + "active": true, + "active_pane": 3, + "layout": { + "type": "split", + "split": 9, + "dir": "right", + "ratio": 0.5, + "a": {"type": "leaf", "pane": 3}, + "b": {"type": "leaf", "pane": 4} + }, + "viewport_splits": [{"split": 9, "width": 0.6666667}], + "panes": [] + }] + }] + }), + TreeCapabilities { viewport_splits: true, viewport_column_resize: false }, + ); + + let Node::Split { id, .. } = &tree.workspaces[0].screens[0].layout else { + panic!("layout should be split"); + }; + assert_eq!(*id, 9); + assert_eq!(tree.workspaces[0].screens[0].viewport_splits.get(&9), Some(&(2.0 / 3.0))); + } + + #[test] + fn parser_ignores_out_of_range_viewport_widths() { + let tree = parse_tree_with_capabilities( + &json!({ + "workspaces": [{ + "id": 1, + "active": true, + "screens": [{ + "id": 2, + "active": true, + "active_pane": 3, + "layout": {"type": "leaf", "pane": 3}, + "viewport_base_width": 1.1, + "viewport_splits": [ + {"split": 8, "width": 0.09}, + {"split": 9, "width": 0.5}, + {"split": 10, "width": 1.01} + ], + "panes": [] + }] + }] + }), + TreeCapabilities { viewport_splits: true, viewport_column_resize: true }, + ); + + let screen = &tree.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, None); + assert_eq!(screen.viewport_splits, BTreeMap::from([(9, 0.5)])); + } + + #[test] + fn parser_ignores_viewport_metadata_without_capabilities() { let tree = parse_tree(&json!({ "workspaces": [{ "id": 1, - "name": "one", "active": true, "screens": [{ "id": 2, "active": true, "active_pane": 3, - "layout": { - "type": "split", - "split": 9, - "dir": "right", - "ratio": 0.5, - "a": {"type": "leaf", "pane": 3}, - "b": {"type": "leaf", "pane": 4} - }, + "layout": {"type": "leaf", "pane": 3}, + "viewport_base_width": 0.8, + "viewport_splits": [{"split": 9, "width": 0.5}], "panes": [] }] }] })); - let Node::Split { id, .. } = &tree.workspaces[0].screens[0].layout else { - panic!("layout should be split"); - }; - assert_eq!(*id, 9); + let screen = &tree.workspaces[0].screens[0]; + assert_eq!(screen.viewport_base_width, None); + assert!(screen.viewport_splits.is_empty()); } #[test] diff --git a/cmux-tui/crates/cmux-tui/src/ui/graphics.rs b/cmux-tui/crates/cmux-tui/src/ui/graphics.rs index 4454d2cb2a77..1667a552c47a 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/graphics.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/graphics.rs @@ -1,10 +1,11 @@ use std::collections::{HashMap, HashSet}; use std::io::Write; +use std::sync::Arc; use std::time::Duration; #[cfg(unix)] use std::time::Instant; -use cmux_tui_core::{Rect, SurfaceId}; +use cmux_tui_core::{BrowserFrame, Rect, SurfaceId}; const ESC: &str = "\x1b"; const CHUNK: usize = 4096; @@ -14,8 +15,8 @@ const PLACEMENT_ID: u32 = 1; pub struct GraphicPlacement { pub surface: SurfaceId, pub rect: Rect, - pub seq: u64, - pub data_b64: String, + pub source_crop_px: Option<(u32, u32)>, + pub frame: Arc, } #[derive(Default)] @@ -40,13 +41,19 @@ impl GraphicsState { for placement in visible_placements { let mut batch = Vec::new(); - let already_sent = - self.transmitted.get(&placement.surface).is_some_and(|seq| *seq == placement.seq); + let already_sent = self + .transmitted + .get(&placement.surface) + .is_some_and(|seq| *seq == placement.frame.seq); if !already_sent { - batch.extend(transmit_png(placement.surface, &placement.data_b64)); - self.transmitted.insert(placement.surface, placement.seq); + batch.extend(transmit_png(placement.surface, &placement.frame.data_b64)); + self.transmitted.insert(placement.surface, placement.frame.seq); } - batch.extend(place_image(placement.surface, placement.rect)); + batch.extend(place_image_cropped( + placement.surface, + placement.rect, + placement.source_crop_px, + )); if !batch.is_empty() { out.push(batch); } @@ -79,10 +86,16 @@ pub fn transmit_png(surface: SurfaceId, data_b64: &str) -> Vec { out } -pub fn place_image(surface: SurfaceId, rect: Rect) -> Vec { +pub fn place_image_cropped( + surface: SurfaceId, + rect: Rect, + source_crop_px: Option<(u32, u32)>, +) -> Vec { let id = image_id(surface); + let crop = + source_crop_px.map_or_else(String::new, |(x, width)| format!(",x={x},w={}", width.max(1))); format!( - "{ESC}7{ESC}[{};{}H{ESC}_Ga=p,i={id},p={PLACEMENT_ID},c={},r={},q=2;{ESC}\\{ESC}8", + "{ESC}7{ESC}[{};{}H{ESC}_Ga=p,i={id},p={PLACEMENT_ID}{crop},c={},r={},q=2;{ESC}\\{ESC}8", rect.y + 1, rect.x + 1, rect.width.max(1), @@ -258,8 +271,12 @@ mod tests { #[test] fn places_at_cursor_rect_with_save_restore() { - let bytes = - String::from_utf8(place_image(2, Rect { x: 4, y: 6, width: 80, height: 24 })).unwrap(); + let bytes = String::from_utf8(place_image_cropped( + 2, + Rect { x: 4, y: 6, width: 80, height: 24 }, + None, + )) + .unwrap(); assert_eq!(bytes, "\x1b7\x1b[7;5H\x1b_Ga=p,i=3,p=1,c=80,r=24,q=2;\x1b\\\x1b8"); } @@ -269,13 +286,32 @@ mod tests { assert_eq!(bytes, "\x1b_Ga=d,d=i,i=42,q=2;\x1b\\"); } + #[test] + fn cropped_placement_selects_a_horizontal_source_slice() { + let bytes = String::from_utf8(place_image_cropped( + 2, + Rect { x: 4, y: 6, width: 40, height: 24 }, + Some((80, 320)), + )) + .unwrap(); + assert!(bytes.contains(",x=80,w=320,c=40,r=24,")); + } + #[test] fn zero_sized_placement_hides_a_previously_visible_image() { let visible = GraphicPlacement { surface: 7, rect: Rect { x: 4, y: 6, width: 80, height: 24 }, - seq: 1, - data_b64: "frame".to_string(), + source_crop_px: None, + frame: Arc::new(BrowserFrame { + session_id: "test".to_string(), + data_b64: "frame".to_string(), + css_width: 80, + css_height: 24, + image_width: 80, + image_height: 24, + seq: 1, + }), }; let collapsed = GraphicPlacement { rect: Rect { height: 0, ..visible.rect }, ..visible.clone() }; diff --git a/cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs b/cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs index b87dcc48a79b..e6959f305b2b 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs @@ -1,6 +1,6 @@ use std::io::Write; use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; use std::time::Duration; @@ -64,7 +64,7 @@ fn submit_snapshot( tx: &SyncSender<()>, placements: Vec, ) { - *slot.lock().unwrap() = Some(placements); + *lock_recover(slot) = Some(placements); match tx.try_send(()) { Ok(()) | Err(TrySendError::Full(())) => {} Err(TrySendError::Disconnected(())) => {} @@ -81,7 +81,7 @@ fn writer_loop( let mut graphics = GraphicsState::default(); while rx.recv().is_ok() { loop { - let next = slot.lock().unwrap().take(); + let next = lock_recover(&slot).take(); let Some(placements) = next else { break }; for batch in graphics.frame_batches(&placements) { let _guard = stdout_lock.lock(); @@ -94,6 +94,10 @@ fn writer_loop( } } +fn lock_recover(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + struct DoneOnDrop(SyncSender<()>); impl Drop for DoneOnDrop { @@ -105,7 +109,19 @@ impl Drop for DoneOnDrop { #[cfg(test)] mod tests { use super::*; - use cmux_tui_core::Rect; + use cmux_tui_core::{BrowserFrame, Rect}; + + fn test_frame(seq: u64, data_b64: &str) -> Arc { + Arc::new(BrowserFrame { + session_id: "test".to_string(), + data_b64: data_b64.to_string(), + css_width: 10, + css_height: 5, + image_width: 10, + image_height: 5, + seq, + }) + } #[test] fn snapshot_slot_is_latest_wins_and_shutdown_is_clean() { @@ -117,8 +133,8 @@ mod tests { vec![GraphicPlacement { surface: 1, rect: Rect { x: 0, y: 0, width: 10, height: 5 }, - seq: 1, - data_b64: "AAAA".to_string(), + source_crop_px: None, + frame: test_frame(1, "AAAA"), }], ); submit_snapshot( @@ -127,14 +143,14 @@ mod tests { vec![GraphicPlacement { surface: 1, rect: Rect { x: 1, y: 1, width: 11, height: 6 }, - seq: 2, - data_b64: "BBBB".to_string(), + source_crop_px: None, + frame: test_frame(2, "BBBB"), }], ); let latest = slot.lock().unwrap().take().expect("latest snapshot"); assert_eq!(latest.len(), 1); - assert_eq!(latest[0].seq, 2); + assert_eq!(latest[0].frame.seq, 2); assert_eq!(latest[0].rect.x, 1); rx.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(rx.try_recv().is_err()); diff --git a/cmux-tui/crates/cmux-tui/src/ui/mod.rs b/cmux-tui/crates/cmux-tui/src/ui/mod.rs index 2a3b58d52115..8a8d8fedd517 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/mod.rs @@ -16,8 +16,10 @@ pub(crate) mod terminal_grid; use cmux_tui_core::Rect; use ratatui::Frame; -use ratatui::layout::Position; +use ratatui::buffer::Buffer; +use ratatui::layout::{Position, Rect as RatatuiRect}; use ratatui::style::{Color, Modifier, Style}; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::app::{App, Hit}; @@ -26,10 +28,75 @@ use crate::localization::catalog; use crate::machine::DurableNoticeLevel; pub(crate) use scrollbar::{ - ScrollbarState, ScrollbarStyle, thumb_geometry, viewport_drag_offset, viewport_jump_offset, + ScrollbarState, ScrollbarStyle, horizontal_drag_offset, horizontal_offset_at, + horizontal_thumb_geometry, thumb_geometry, viewport_drag_offset, viewport_jump_offset, viewport_thumb_geometry, }; +#[derive(Default)] +pub(crate) struct ReusableRowBuffer { + buffer: Option, +} + +impl ReusableRowBuffer { + pub(crate) fn take(&mut self, width: u16) -> Buffer { + let mut buffer = + self.buffer.take().unwrap_or_else(|| Buffer::empty(RatatuiRect::default())); + buffer.resize(RatatuiRect::new(0, 0, width, 1)); + buffer.reset(); + buffer + } + + pub(crate) fn put(&mut self, buffer: Buffer) { + debug_assert!(self.buffer.is_none(), "row scratch buffer returned twice"); + self.buffer = Some(buffer); + } +} + +/// Copy one logical buffer row into a visible destination slice. +/// +/// Ratatui stores a wide glyph in its lead cell and leaves its following +/// cell blank. Blanking a lead or tail cut by either crop boundary prevents +/// the terminal renderer from overwriting an adjacent pane or wrapping. +pub(crate) fn copy_buffer_row_cropped( + source: &Buffer, + source_row: u16, + source_x: u16, + target: &mut Buffer, + target_rect: Rect, +) -> u16 { + let source_y = source.area.y.saturating_add(source_row); + let source_left = source.area.x.saturating_add(source_x); + let source_right = source.area.x.saturating_add(source.area.width); + let target_right = target.area.x.saturating_add(target.area.width); + let target_bottom = target.area.y.saturating_add(target.area.height); + if source_y >= source.area.y.saturating_add(source.area.height) + || source_left >= source_right + || target_rect.y < target.area.y + || target_rect.y >= target_bottom + || target_rect.x < target.area.x + || target_rect.x >= target_right + { + return 0; + } + let width = target_rect + .width + .min(source_right.saturating_sub(source_left)) + .min(target_right.saturating_sub(target_rect.x)); + let partial_left = + source_left > source.area.x && source[(source_left - 1, source_y)].symbol().width() > 1; + for dx in 0..width { + let source_cell = &source[(source_left + dx, source_y)]; + let target_cell = &mut target[(target_rect.x + dx, target_rect.y)]; + *target_cell = source_cell.clone(); + let symbol_width = source_cell.symbol().width() as u16; + if (dx == 0 && partial_left) || symbol_width > width.saturating_sub(dx) { + target_cell.set_symbol(" "); + } + } + width +} + pub fn draw(app: &mut App, frame: &mut Frame) { app.reset_frame_cursor_spec(); let area = frame.area(); @@ -68,6 +135,7 @@ pub fn draw(app: &mut App, frame: &mut Frame) { frame.set_cursor_position(Position::new(x, y)); } draw_durable_notice_banner(app, frame); + sanitize_render_buffer(frame.buffer_mut()); } fn draw_durable_notice_banner(app: &mut App, frame: &mut Frame) { @@ -115,6 +183,18 @@ fn draw_surface_status(app: &App, frame: &mut Frame) { ); } +fn sanitize_render_buffer(buffer: &mut Buffer) { + // Ratatui rejects control bytes while diffing a frame. Keep the + // component-level text sanitizers for useful output, then enforce this + // final invariant across titles, plugin UI, browser text, and future + // renderers so one malformed cell cannot take down the frontend. + for cell in &mut buffer.content { + if cell.symbol().chars().any(char::is_control) { + cell.set_symbol(" "); + } + } +} + /// Status bar: the active workspace's screens, one clickable segment per /// screen plus a trailing `+` for a new one. It spans only the pane /// region (it does not extend under the sidebar). @@ -135,7 +215,7 @@ fn draw_status_bar(app: &mut App, frame: &mut Frame) { let mut hits = Vec::new(); let put = |frame: &mut Frame, x: &mut u16, text: &str, style: Style| -> (u16, u16) { let start = *x; - let width = (text.chars().count() as u16).min(area.width.saturating_sub(*x)); + let width = text.width().min(area.width.saturating_sub(*x) as usize) as u16; if width > 0 { frame.buffer_mut().set_stringn(*x, status_y, text, width as usize, style); *x += width; @@ -165,17 +245,39 @@ fn draw_status_bar(app: &mut App, frame: &mut Frame) { if width > 0 { hits.push((Rect { x: start, y: status_y, width, height: 1 }, Hit::NewScreen)); } - app.hits.extend(hits); - // Session label / status message, right-aligned. Prefix help renders // over the pane border above this row. + let available_label_width = area.width.saturating_sub(x) as usize; let label = app .status_message .as_ref() - .map(|msg| format!(" {} ", truncate(msg, area.width.saturating_sub(x) as usize))) - .unwrap_or_else(|| format!("[{}] ", app.session_label)); - let label_w = label.chars().count() as u16; - if x + label_w < area.width { + .map(|msg| format!(" {} ", truncate(msg, available_label_width.saturating_sub(2)))) + .unwrap_or_else(|| { + format!("[{}] ", truncate(&app.session_label, available_label_width.saturating_sub(3))) + }); + let label_w = label.width().min(area.width as usize) as u16; + let track_end = area.width.saturating_sub(label_w); + let track_start = x.saturating_add(1); + let track_width = track_end.saturating_sub(track_start.saturating_add(1)); + if let Some((content_width, viewport_width, offset)) = app.horizontal_scrollbar_state() + && track_width > 0 + { + let track = Rect { x: track_start, y: status_y, width: track_width, height: 1 }; + let track_style = base.fg(chrome.status_dim_fg); + for cell_x in track.x..track.x + track.width { + frame.buffer_mut()[(cell_x, status_y)].set_symbol("─").set_style(track_style); + } + let (thumb_x, thumb_width) = + horizontal_thumb_geometry(content_width, viewport_width, offset, track.width); + let thumb_style = base.fg(chrome.scrollbar_thumb_active_fg); + for cell_x in track.x + thumb_x..track.x + thumb_x + thumb_width { + frame.buffer_mut()[(cell_x, status_y)].set_symbol("━").set_style(thumb_style); + } + hits.push((track, Hit::HorizontalScrollbar { track })); + } + app.hits.extend(hits); + + if x.saturating_add(label_w) <= area.width { frame.buffer_mut().set_stringn( area.width - label_w, status_y, @@ -261,14 +363,58 @@ fn draw_prefix_help_bar(app: &App, frame: &mut Frame, bar_x: u16, y: u16) { } } +const TRUNCATE_BYTES_PER_CELL: usize = 128; +const TRUNCATE_MAX_OUTPUT_BYTES: usize = 4_096; + pub(crate) fn truncate(s: &str, max: usize) -> String { - if s.chars().count() <= max { - s.to_string() + if max == 0 { + return String::new(); + } + + // Display width alone does not bound combining marks or other zero-width + // scalars. Limit the UTF-8 prefix inspected and copied on every paint. + let output_byte_budget = max + .saturating_mul(TRUNCATE_BYTES_PER_CELL) + .clamp(TRUNCATE_BYTES_PER_CELL, TRUNCATE_MAX_OUTPUT_BYTES); + let mut prefix_end = s.len().min(output_byte_budget); + while !s.is_char_boundary(prefix_end) { + prefix_end -= 1; + } + let byte_truncated = prefix_end < s.len(); + let bounded_prefix = &s[..prefix_end]; + + if !byte_truncated && bounded_prefix.width() <= max { + return bounded_prefix.to_string(); + } + + // A byte cutoff can land inside one extended grapheme. Conservatively + // discard that final grapheme so a pathological first grapheme becomes + // just an ellipsis instead of leaking a partial combining sequence. + let complete_prefix = if byte_truncated { + let last_grapheme_start = + bounded_prefix.grapheme_indices(true).next_back().map(|(index, _)| index).unwrap_or(0); + &bounded_prefix[..last_grapheme_start] } else { - let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); - out.push('…'); - out + bounded_prefix + }; + let content_width = max - 1; + let content_byte_budget = output_byte_budget.saturating_sub('…'.len_utf8()); + let mut width: usize = 0; + let mut out = String::with_capacity( + complete_prefix.len().min(content_byte_budget).saturating_add('…'.len_utf8()), + ); + for grapheme in complete_prefix.graphemes(true) { + let grapheme_width = grapheme.width(); + if width.saturating_add(grapheme_width) > content_width + || out.len().saturating_add(grapheme.len()) > content_byte_budget + { + break; + } + out.push_str(grapheme); + width += grapheme_width; } + out.push('…'); + out } pub(crate) fn middle_truncate(input: &str, max_chars: usize) -> String { @@ -293,7 +439,14 @@ pub(crate) fn middle_truncate(input: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { - use super::middle_truncate; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Style; + + use super::{ + ReusableRowBuffer, copy_buffer_row_cropped, middle_truncate, sanitize_render_buffer, + truncate, + }; #[test] fn middle_truncates_for_narrow_columns() { @@ -302,4 +455,81 @@ mod tests { assert_eq!(middle_truncate("abc", 3), "abc"); assert_eq!(middle_truncate("abc", 0), ""); } + + #[test] + fn truncation_uses_terminal_cell_width_and_preserves_graphemes() { + assert_eq!(truncate("復元失敗", 5), "復元…"); + assert_eq!(truncate("e\u{301}clair", 2), "e\u{301}…"); + assert_eq!(truncate("復元", 1), "…"); + assert_eq!(truncate("復元", 0), ""); + } + + #[test] + fn truncation_bounds_pathological_zero_width_input() { + let oversized_grapheme = format!("x{}", "\u{301}".repeat(10_000)); + assert_eq!(truncate(&oversized_grapheme, 20), "…"); + + let invisible = "\u{200b}".repeat(10_000); + assert_eq!(truncate(&invisible, 0), ""); + assert!(truncate(&invisible, 4).len() <= 4_096); + } + + #[test] + fn render_buffer_rejects_control_bytes_from_every_ui_source() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 3, 1)); + buffer[(0, 0)].set_symbol("\u{1b}"); + buffer[(1, 0)].set_symbol("bad\ncell"); + buffer[(2, 0)].set_symbol("ok"); + + sanitize_render_buffer(&mut buffer); + + assert_eq!(buffer[(0, 0)].symbol(), " "); + assert_eq!(buffer[(1, 0)].symbol(), " "); + assert_eq!(buffer[(2, 0)].symbol(), "ok"); + } + + #[test] + fn cropped_buffer_rows_blank_partial_wide_glyphs_at_both_edges() { + let mut source = Buffer::empty(Rect::new(0, 0, 5, 1)); + source.set_string(0, 0, "a界b", Style::default()); + assert_eq!(source[(1, 0)].symbol(), "界"); + + let draw_crop = |source_x| { + let mut target = Buffer::empty(Rect::new(0, 0, 2, 1)); + copy_buffer_row_cropped( + &source, + 0, + source_x, + &mut target, + cmux_tui_core::Rect { x: 0, y: 0, width: 2, height: 1 }, + ); + target + }; + + let clipped_lead = draw_crop(0); + assert_eq!(clipped_lead[(0, 0)].symbol(), "a"); + assert_eq!(clipped_lead[(1, 0)].symbol(), " "); + + let complete = draw_crop(1); + assert_eq!(complete[(0, 0)].symbol(), "界"); + assert_eq!(complete[(1, 0)].symbol(), " "); + + let clipped_tail = draw_crop(2); + assert_eq!(clipped_tail[(0, 0)].symbol(), " "); + assert_eq!(clipped_tail[(1, 0)].symbol(), "b"); + } + + #[test] + fn reusable_row_buffer_keeps_its_allocation_for_smaller_rows() { + let mut scratch = ReusableRowBuffer::default(); + let first = scratch.take(512); + let pointer = first.content.as_ptr(); + let capacity = first.content.capacity(); + scratch.put(first); + + let second = scratch.take(256); + assert_eq!(second.area.width, 256); + assert_eq!(second.content.as_ptr(), pointer); + assert_eq!(second.content.capacity(), capacity); + } } diff --git a/cmux-tui/crates/cmux-tui/src/ui/omnibar.rs b/cmux-tui/crates/cmux-tui/src/ui/omnibar.rs index 7c5b6d5ba11c..765a210d2d8f 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/omnibar.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/omnibar.rs @@ -1,8 +1,10 @@ use cmux_tui_core::{BrowserStatus, Rect, SurfaceKind}; use ratatui::Frame; +use ratatui::buffer::Buffer; use ratatui::style::{Modifier, Style}; +use unicode_width::UnicodeWidthStr; -use super::truncate; +use super::{copy_buffer_row_cropped, truncate}; use crate::app::{App, OmnibarHit, PaneArea}; const BACK_X: u16 = 1; @@ -11,14 +13,14 @@ const RELOAD_X: u16 = 5; const EDIT_START_X: u16 = 7; const TEXT_START_X: u16 = 9; -pub fn hit(rect: Rect, x: u16, y: u16, editing: bool) -> Option { +pub fn hit(rect: Rect, source_x: u16, x: u16, y: u16, editing: bool) -> Option { if !rect.contains(x, y) { return None; } if editing { return Some(OmnibarHit::Edit); } - let rel = x.saturating_sub(rect.x); + let rel = source_x.saturating_add(x.saturating_sub(rect.x)); match rel { BACK_X => Some(OmnibarHit::Back), FORWARD_X => Some(OmnibarHit::Forward), @@ -30,7 +32,20 @@ pub fn hit(rect: Rect, x: u16, y: u16, editing: bool) -> Option { pub fn draw(app: &mut App, frame: &mut Frame, area: &PaneArea) -> Option<(u16, u16)> { let rect = area.omnibar?; - if rect.width == 0 || rect.height == 0 { + let source_x = area.omnibar_source_x(); + let full_width = area.full_omnibar_width(); + let screen = frame.area(); + let screen_right = screen.x.saturating_add(screen.width); + let screen_bottom = screen.y.saturating_add(screen.height); + if rect.width == 0 + || rect.height == 0 + || full_width == 0 + || source_x >= full_width + || rect.x < screen.x + || rect.x >= screen_right + || rect.y < screen.y + || rect.y >= screen_bottom + { return None; } let surface = app.session.surface(area.surface)?; @@ -42,29 +57,63 @@ pub fn draw(app: &mut App, frame: &mut Frame, area: &PaneArea) -> Option<(u16, u .omnibar .as_ref() .is_some_and(|state| state.pane == area.pane && state.surface == area.surface); - if editing { - draw_editing(app, frame, rect) + if area.viewport.is_none() && rect.width <= screen_right.saturating_sub(rect.x) { + if editing { + return draw_editing(app, frame.buffer_mut(), rect).map(|cursor_x| (cursor_x, rect.y)); + } + let hover_x = app.hover.and_then(|(x, y)| rect.contains(x, y).then_some(x)); + draw_idle(app, frame.buffer_mut(), area, rect, &surface, hover_x); + return None; + } + + let visible_width = rect + .width + .min(full_width.saturating_sub(source_x)) + .min(screen_right.saturating_sub(rect.x)); + let logical_rect = Rect { x: 0, y: 0, width: full_width, height: 1 }; + let mut logical = app.chrome_row_scratch.take(full_width); + let cursor = if editing { + draw_editing(app, &mut logical, Rect { x: source_x, width: visible_width, ..logical_rect }) } else { - draw_idle(app, frame, area, rect, &surface); + let hover_x = app.hover.and_then(|(x, y)| { + rect.contains(x, y).then(|| source_x.saturating_add(x.saturating_sub(rect.x))) + }); + draw_idle(app, &mut logical, area, logical_rect, &surface, hover_x); None - } + }; + + copy_buffer_row_cropped( + &logical, + 0, + source_x, + frame.buffer_mut(), + Rect { width: visible_width, height: 1, ..rect }, + ); + app.chrome_row_scratch.put(logical); + cursor + .filter(|cursor_x| { + *cursor_x >= source_x && *cursor_x < source_x.saturating_add(visible_width) + }) + .map(|cursor_x| (rect.x + cursor_x - source_x, rect.y)) } fn draw_idle( app: &App, - frame: &mut Frame, + buffer: &mut Buffer, area: &PaneArea, rect: Rect, surface: &crate::session::SurfaceHandle, + hover_x: Option, ) { let chrome = app.chrome; let base = Style::default().fg(chrome.omnibar_fg); - fill(frame, rect, base); - put(frame, rect, 0, " ", base); - put_nav(frame, app, rect, BACK_X, "‹", base); - put_nav(frame, app, rect, FORWARD_X, "›", base); - put_nav(frame, app, rect, RELOAD_X, "⟳", base); - put(frame, rect, 7, "│", base.fg(chrome.omnibar_sep_fg)); + fill(buffer, rect, base); + put(buffer, rect, 0, " ", base); + let hover = base.fg(chrome.omnibar_hover_fg).add_modifier(Modifier::BOLD); + put_nav(buffer, rect, BACK_X, "‹", base, hover, hover_x); + put_nav(buffer, rect, FORWARD_X, "›", base, hover, hover_x); + put_nav(buffer, rect, RELOAD_X, "⟳", base, hover, hover_x); + put(buffer, rect, 7, "│", base.fg(chrome.omnibar_sep_fg)); if rect.width <= TEXT_START_X { return; @@ -81,7 +130,7 @@ fn draw_idle( }); let loading = matches!(surface.browser_status(), Some(BrowserStatus::Starting)) || (matches!(surface.browser_status(), Some(BrowserStatus::Live)) - && surface.browser_frame().is_none()); + && !surface.has_browser_frame()); if loading { label.push('…'); } @@ -97,33 +146,33 @@ fn draw_idle( .find(|tab| tab.surface == area.surface) .is_some_and(|tab| tab.browser_frames_stalled); if (surface.browser_frames_stalled() || tree_stalled) && max > 0 { - let suffix_width = suffix.chars().count(); + let suffix_width = suffix.width(); if max > suffix_width { let label_max = max - suffix_width; let text = truncate(&label, label_max); - put(frame, rect, TEXT_START_X, &text, base); + put(buffer, rect, TEXT_START_X, &text, base); put( - frame, + buffer, rect, - TEXT_START_X + text.chars().count() as u16, + TEXT_START_X + text.width() as u16, suffix, base.fg(chrome.omnibar_dim_fg), ); } else { let text = truncate(suffix.trim_start(), max); - put(frame, rect, TEXT_START_X, &text, base.fg(chrome.omnibar_dim_fg)); + put(buffer, rect, TEXT_START_X, &text, base.fg(chrome.omnibar_dim_fg)); } } else { let text = truncate(&label, max); - put(frame, rect, TEXT_START_X, &text, base); + put(buffer, rect, TEXT_START_X, &text, base); } } -fn draw_editing(app: &mut App, frame: &mut Frame, rect: Rect) -> Option<(u16, u16)> { +fn draw_editing(app: &mut App, buffer: &mut Buffer, rect: Rect) -> Option { let chrome = app.chrome; let state = app.omnibar.as_mut()?; let base = Style::default().bg(chrome.omnibar_edit_bg).fg(chrome.omnibar_edit_fg); - fill(frame, rect, base); + fill(buffer, rect, base); if rect.width == 0 { return None; } @@ -131,46 +180,48 @@ fn draw_editing(app: &mut App, frame: &mut Frame, rect: Rect) -> Option<(u16, u1 let width = rect.width as usize; let (shown, cursor_col) = state.input.visible_text_and_cursor(width); let style = if state.select_all { base.add_modifier(Modifier::REVERSED) } else { base }; - put(frame, rect, 0, &shown, style); - Some((rect.x + cursor_col as u16, rect.y)) + put(buffer, rect, 0, &shown, style); + Some(rect.x + cursor_col as u16) } -fn put_nav(frame: &mut Frame, app: &App, rect: Rect, rel_x: u16, text: &str, base: Style) { +fn put_nav( + buffer: &mut Buffer, + rect: Rect, + rel_x: u16, + text: &str, + base: Style, + hover: Style, + hover_x: Option, +) { if rel_x >= rect.width { return; } - let cell = Rect { x: rect.x + rel_x, y: rect.y, width: 1, height: 1 }; - let hovered = app.hover.is_some_and(|(x, y)| cell.contains(x, y)); - let style = if hovered { - base.fg(app.chrome.omnibar_hover_fg).add_modifier(Modifier::BOLD) - } else { - base - }; - put(frame, rect, rel_x, text, style); + let style = if hover_x == Some(rect.x + rel_x) { hover } else { base }; + put(buffer, rect, rel_x, text, style); } -fn fill(frame: &mut Frame, rect: Rect, style: Style) { - let screen = frame.area(); +fn fill(buffer: &mut Buffer, rect: Rect, style: Style) { + let screen = buffer.area; let max_x = (rect.x + rect.width).min(screen.width); if rect.y >= screen.height { return; } for x in rect.x..max_x { - frame.buffer_mut()[(x, rect.y)].set_symbol(" ").set_style(style); + buffer[(x, rect.y)].set_symbol(" ").set_style(style); } } -fn put(frame: &mut Frame, rect: Rect, rel_x: u16, text: &str, style: Style) { +fn put(buffer: &mut Buffer, rect: Rect, rel_x: u16, text: &str, style: Style) { if rel_x >= rect.width { return; } - let screen = frame.area(); + let screen = buffer.area; if rect.y >= screen.height { return; } let x = rect.x + rel_x; let max = rect.width.saturating_sub(rel_x) as usize; - frame.buffer_mut().set_stringn(x, rect.y, text, max, style); + buffer.set_stringn(x, rect.y, text, max, style); } #[cfg(test)] @@ -182,8 +233,16 @@ mod tests { #[test] fn editing_omnibar_treats_entire_row_as_edit_text() { let rect = Rect { x: 10, y: 2, width: 20, height: 1 }; - assert_eq!(hit(rect, 11, 2, true), Some(OmnibarHit::Edit)); - assert_eq!(hit(rect, 13, 2, true), Some(OmnibarHit::Edit)); - assert_eq!(hit(rect, 15, 2, true), Some(OmnibarHit::Edit)); + assert_eq!(hit(rect, 0, 11, 2, true), Some(OmnibarHit::Edit)); + assert_eq!(hit(rect, 0, 13, 2, true), Some(OmnibarHit::Edit)); + assert_eq!(hit(rect, 0, 15, 2, true), Some(OmnibarHit::Edit)); + } + + #[test] + fn clipped_omnibar_hits_keep_their_logical_columns() { + let rect = Rect { x: 20, y: 2, width: 8, height: 1 }; + assert_eq!(hit(rect, 4, 21, 2, false), Some(OmnibarHit::Reload)); + assert_eq!(hit(rect, 4, 23, 2, false), Some(OmnibarHit::Edit)); + assert_eq!(hit(rect, 4, 20, 2, false), None); } } diff --git a/cmux-tui/crates/cmux-tui/src/ui/pane.rs b/cmux-tui/crates/cmux-tui/src/ui/pane.rs index 93c51c1744f6..58e6abc87424 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/pane.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/pane.rs @@ -12,8 +12,9 @@ use cmux_tui_core::{BrowserStatus, Rect, SurfaceKind}; use ghostty_vt::RenderState; use ratatui::Frame; use ratatui::style::{Color, Modifier, Style}; +use unicode_width::UnicodeWidthStr; -use super::{ScrollbarState, ScrollbarStyle, thumb_geometry, truncate}; +use super::{ScrollbarState, ScrollbarStyle, copy_buffer_row_cropped, thumb_geometry, truncate}; use crate::app::{App, FocusTarget, Hit, PaneArea, PaneEdge, Selection}; use crate::config::{Theme, tab_label}; use crate::session::{ClientInfo, TabNotificationView}; @@ -143,21 +144,29 @@ fn draw_box(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool) { if x1 >= screen.width || y1 >= screen.height { return; } - for x in x0 + 1..x1 { + for x in x0..=x1 { buf[(x, y1)].set_symbol("─").set_style(style); } for y in y0 + 1..y1 { - buf[(x0, y)].set_symbol("│").set_style(style); - buf[(x1, y)].set_symbol("│").set_style(style); + if area.has_left_edge() { + buf[(x0, y)].set_symbol("│").set_style(style); + } + if area.has_right_edge() { + buf[(x1, y)].set_symbol("│").set_style(style); + } + } + if area.has_left_edge() { + buf[(x0, y0)].set_symbol("┌").set_style(style); + buf[(x0, y1)].set_symbol("└").set_style(style); + } + if area.has_right_edge() { + buf[(x1, y0)].set_symbol("┐").set_style(style); + buf[(x1, y1)].set_symbol("┘").set_style(style); } - buf[(x0, y0)].set_symbol("┌").set_style(style); - buf[(x1, y0)].set_symbol("┐").set_style(style); - buf[(x0, y1)].set_symbol("└").set_style(style); - buf[(x1, y1)].set_symbol("┘").set_style(style); if let Some(label) = app.client_border_labels.get(&area.surface) { - let width = label.chars().count() as u16; - if width + 2 < rect.width { + let width = label.width() as u16; + if area.has_left_edge() && area.has_right_edge() && width + 2 < rect.width { let hit = Rect { x: x0 + 1, y: y1, width, height: 1 }; buf.set_stringn(hit.x, hit.y, label, width as usize, style); app.hits.push((hit, Hit::Clients { surface: area.surface })); @@ -168,6 +177,17 @@ fn draw_box(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool) { /// The top border row: `┌` + tabs + `+` + `─...─` + `┐`, with `‹`/`›` /// overflow arrows when the tabs don't fit. Always visible so a new tab /// is always one click away. +fn clip_tab_bar_rect(logical: Rect, bar: Rect, source_x: u16) -> Option { + let start = logical.x.max(source_x); + let end = logical.x.saturating_add(logical.width).min(source_x.saturating_add(bar.width)); + (end > start).then(|| Rect { + x: bar.x.saturating_add(start.saturating_sub(source_x)), + y: bar.y, + width: end - start, + height: 1, + }) +} + fn draw_tab_bar(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool) { let Some(bar) = area.bar else { return }; let Some(screen_view) = app.tree.active_screen() else { return }; @@ -194,9 +214,21 @@ fn draw_tab_bar(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool .and_then(|(target_pane, index)| (target_pane == pane_id).then_some(index)); let screen = frame.area(); - if bar.width < 2 || bar.y >= screen.height { + let full_width = area.viewport.map_or(bar.width, |clip| clip.full_rect_width); + let source_x = area.viewport.map_or(0, |clip| clip.rect_source_x); + let screen_right = screen.x.saturating_add(screen.width); + let screen_bottom = screen.y.saturating_add(screen.height); + if full_width < 2 + || bar.width == 0 + || source_x >= full_width + || bar.x < screen.x + || bar.x >= screen_right + || bar.y < screen.y + || bar.y >= screen_bottom + { return; } + let visible_bar = Rect { width: bar.width.min(screen_right.saturating_sub(bar.x)), ..bar }; let theme = app.config.theme; let chrome = app.chrome; let style = border_style(app, focused); @@ -226,23 +258,37 @@ fn draw_tab_bar(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool }, ) }; + let visible_rect = |rect| clip_tab_bar_rect(rect, visible_bar, source_x); // Hover highlight for the bar's controls (+, ‹, ›). - let hovered_ctrl = |rect: Rect| hover.is_some_and(|(hx, hy)| rect.contains(hx, hy)); let ctrl_style = |rect: Rect| { - if hovered_ctrl(rect) { + if visible_rect(rect) + .is_some_and(|rect| hover.is_some_and(|(hx, hy)| rect.contains(hx, hy))) + { Style::default().fg(chrome.tab_control_hover_fg).add_modifier(Modifier::BOLD) } else { base } }; - // Fill the whole top row with the border line first; tabs overlay it. - let buf = frame.buffer_mut(); - let (x0, x1) = (bar.x, bar.x + bar.width - 1); - buf[(x0, bar.y)].set_symbol("┌").set_style(style); - buf[(x1, bar.y)].set_symbol("┐").set_style(style); - for x in x0 + 1..x1 { - buf[(x, bar.y)].set_symbol("─").set_style(style); + // Unclipped panes render directly into the frame. Cropped panes retain + // their full logical geometry in a one-row scratch buffer, then use the + // shared wide-glyph-safe crop path. + let direct = area.viewport.is_none() && bar.width <= screen_right.saturating_sub(bar.x); + let mut logical = (!direct).then(|| app.chrome_row_scratch.take(full_width)); + let (origin_x, origin_y) = if direct { (bar.x, bar.y) } else { (0, 0) }; + let buf = match logical.as_mut() { + Some(logical) => logical, + None => frame.buffer_mut(), + }; + let (x0, x1) = (0, full_width - 1); + for x in x0..=x1 { + buf[(origin_x + x, origin_y)].set_symbol("─").set_style(style); + } + if area.has_left_edge() { + buf[(origin_x + x0, origin_y)].set_symbol("┌").set_style(style); + } + if area.has_right_edge() { + buf[(origin_x + x1, origin_y)].set_symbol("┐").set_style(style); } // Layout the tab labels: " 1 zsh " ... " + ", scrolled so the range @@ -253,12 +299,12 @@ fn draw_tab_bar(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool .map(|t| { let label = format!(" {} ", truncate(t, 16)); // Pad to the configured minimum width, keeping the text centered-ish. - let short = min_w.saturating_sub(label.chars().count()); + let short = min_w.saturating_sub(label.width()); format!("{}{}{}", " ".repeat(short / 2), label, " ".repeat(short - short / 2)) }) .collect(); - let widths: Vec = labels.iter().map(|l| l.chars().count() as u16).collect(); - let inner_w = bar.width.saturating_sub(2); // between the corners + let widths: Vec = labels.iter().map(|label| label.width() as u16).collect(); + let inner_w = full_width.saturating_sub(2); // between the corners let plus_w: u16 = 3; // " + " let arrow_w: u16 = 1; @@ -285,8 +331,8 @@ fn draw_tab_bar(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool let mut x = x0 + 1; let max_x = x1; // exclusive if scroll > 0 { - let rect = Rect { x, y: bar.y, width: arrow_w, height: 1 }; - buf.set_stringn(x, bar.y, "‹", 1, ctrl_style(rect)); + let rect = Rect { x, y: 0, width: arrow_w, height: 1 }; + buf.set_stringn(origin_x + x, origin_y, "‹", 1, ctrl_style(rect)); hits.push((rect, Hit::TabScroll { pane: pane_id, delta: -1 })); x += arrow_w; } @@ -308,38 +354,49 @@ fn draw_tab_bar(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool if tab_drag.is_some_and(|drag| pane.tabs[i].surface == drag.surface) { style = style.add_modifier(Modifier::DIM); } - buf.set_stringn(x, bar.y, label, w as usize, style); + buf.set_stringn(origin_x + x, origin_y, label, w as usize, style); if tab_cfg.solid_background && is_active { - buf[(x, bar.y)].set_symbol("▎").set_style(style.fg(theme.tab_rail)); + buf[(origin_x + x, origin_y)].set_symbol("▎").set_style(style.fg(theme.tab_rail)); } if drop_index == Some(i) && x < max_x { - buf[(x, bar.y)] + buf[(origin_x + x, origin_y)] .set_symbol("▌") .set_style(Style::default().fg(theme.tab_rail).add_modifier(Modifier::BOLD)); } - hits.push(( - Rect { x, y: bar.y, width: w, height: 1 }, - Hit::Tab { pane: pane_id, index: i }, - )); + hits.push((Rect { x, y: 0, width: w, height: 1 }, Hit::Tab { pane: pane_id, index: i })); x += w; } if drop_index == Some(tabs.len()) && x < max_x { - buf[(x, bar.y)] + buf[(origin_x + x, origin_y)] .set_symbol("▌") .set_style(Style::default().fg(theme.tab_rail).add_modifier(Modifier::BOLD)); } if overflow && x + arrow_w <= max_x { - let rect = Rect { x, y: bar.y, width: arrow_w, height: 1 }; - buf.set_stringn(x, bar.y, "›", 1, ctrl_style(rect)); + let rect = Rect { x, y: 0, width: arrow_w, height: 1 }; + buf.set_stringn(origin_x + x, origin_y, "›", 1, ctrl_style(rect)); hits.push((rect, Hit::TabScroll { pane: pane_id, delta: 1 })); x += arrow_w; } if x + plus_w <= max_x { - let rect = Rect { x, y: bar.y, width: plus_w, height: 1 }; - buf.set_stringn(x, bar.y, " + ", plus_w as usize, ctrl_style(rect)); + let rect = Rect { x, y: 0, width: plus_w, height: 1 }; + buf.set_stringn(origin_x + x, origin_y, " + ", plus_w as usize, ctrl_style(rect)); hits.push((rect, Hit::NewTab { pane: pane_id })); } - app.hits.extend(hits); + + if let Some(logical) = logical.take() { + let visible_width = visible_bar.width.min(full_width.saturating_sub(source_x)); + copy_buffer_row_cropped( + &logical, + 0, + source_x, + frame.buffer_mut(), + Rect { width: visible_width, height: 1, ..bar }, + ); + app.chrome_row_scratch.put(logical); + } + app.hits.extend(hits.into_iter().filter_map(|(rect, hit)| { + clip_tab_bar_rect(rect, visible_bar, source_x).map(|rect| (rect, hit)) + })); } /// Draw one pane's terminal content; returns the frame cursor position @@ -372,7 +429,9 @@ fn draw_content(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool let Ok(render) = surface.render_frame(rs) else { return DrawCursors::default(); }; - let live = super::terminal_grid::rendered_viewport_rect(rect, frame.area(), &render); + let source_x = area.content_source_x(); + let live = + super::terminal_grid::rendered_viewport_rect_cropped(rect, frame.area(), &render, source_x); app.rendered_terminal_bounds.insert(area.surface, live); app.rendered_terminal_sizes.insert(area.surface, render.frame.size); if focused && app.menu.is_none() && app.prompt.is_none() && app.pairing_dialog.is_none() { @@ -384,9 +443,10 @@ fn draw_content(app: &mut App, frame: &mut Frame, area: &PaneArea, focused: bool ); } - let cursor = super::terminal_grid::draw_render_frame( + let cursor = super::terminal_grid::draw_render_frame_cropped( frame, rect, + source_x, &render, &theme, &app.chrome, @@ -424,7 +484,7 @@ fn draw_browser_content( Some("browser panes are not supported over attach yet".to_string()) } else if !app.graphics_supported { Some("terminal has no kitty graphics support".to_string()) - } else if surface.browser_frame().is_none() { + } else if !surface.has_browser_frame() { let url = surface .browser_url() .or_else(|| { @@ -445,7 +505,7 @@ fn draw_browser_content( return; } let text = truncate(&message, max_cols as usize); - let text_w = text.chars().count() as u16; + let text_w = text.width() as u16; let x = rect.x + max_cols.saturating_sub(text_w) / 2; let y = rect.y + max_rows / 2; frame.buffer_mut().set_stringn( @@ -522,16 +582,38 @@ fn push_resize_hits(app: &mut App, area: &PaneArea) { (Rect { x, y, width: 1, height: 1 }, Hit::PaneResize { horizontal, vertical }) }; let mut hits = vec![ - cell(x0, y0, Some((pane, PaneEdge::Left)), Some((pane, PaneEdge::Top))), - cell(x1, y0, Some((pane, PaneEdge::Right)), Some((pane, PaneEdge::Top))), - cell(x0, y1, Some((pane, PaneEdge::Left)), Some((pane, PaneEdge::Bottom))), - cell(x1, y1, Some((pane, PaneEdge::Right)), Some((pane, PaneEdge::Bottom))), + cell( + x0, + y0, + area.has_left_edge().then_some((pane, PaneEdge::Left)), + Some((pane, PaneEdge::Top)), + ), + cell( + x1, + y0, + area.has_right_edge().then_some((pane, PaneEdge::Right)), + Some((pane, PaneEdge::Top)), + ), + cell( + x0, + y1, + area.has_left_edge().then_some((pane, PaneEdge::Left)), + Some((pane, PaneEdge::Bottom)), + ), + cell( + x1, + y1, + area.has_right_edge().then_some((pane, PaneEdge::Right)), + Some((pane, PaneEdge::Bottom)), + ), ]; - if rect.height > 2 { + if area.has_left_edge() && rect.height > 2 { hits.push(( Rect { x: x0, y: y0 + 1, width: 1, height: rect.height - 2 }, Hit::PaneResize { horizontal: Some((pane, PaneEdge::Left)), vertical: None }, )); + } + if area.has_right_edge() && rect.height > 2 { hits.push(( Rect { x: x1, y: y0 + 1, width: 1, height: rect.height - 2 }, Hit::PaneResize { horizontal: Some((pane, PaneEdge::Right)), vertical: None }, @@ -552,8 +634,16 @@ fn push_resize_hits(app: &mut App, area: &PaneArea) { #[cfg(test)] mod tests { - use super::client_border_labels; + use super::{client_border_labels, clip_tab_bar_rect}; use crate::session::{ClientInfo, ClientSizeInfo}; + use cmux_tui_core::Rect; + + #[test] + fn offscreen_tab_hit_clipping_returns_none_without_underflow() { + let bar = Rect { x: 0, y: 0, width: 8, height: 1 }; + let logical = Rect { x: 1, y: 0, width: 3, height: 1 }; + assert_eq!(clip_tab_bar_rect(logical, bar, 10), None); + } fn client(id: u64, surface: u64, size: Option<(u16, u16)>) -> ClientInfo { ClientInfo { diff --git a/cmux-tui/crates/cmux-tui/src/ui/scrollbar.rs b/cmux-tui/crates/cmux-tui/src/ui/scrollbar.rs index 1c539aeb6372..f871ad4441d5 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/scrollbar.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/scrollbar.rs @@ -118,7 +118,7 @@ pub(crate) fn viewport_drag_offset( } #[cfg(test)] -mod tests { +mod viewport_tests { use super::*; #[test] @@ -164,3 +164,118 @@ mod tests { assert_eq!(buffer[(0, 0)].symbol(), " "); } } + +/// Thumb position and length for a horizontally scrollable viewport. +pub(crate) fn horizontal_thumb_geometry( + content_width: u64, + viewport_width: u16, + offset: u64, + track_width: u16, +) -> (u16, u16) { + if content_width == 0 || viewport_width == 0 || track_width == 0 { + return (0, 0); + } + let thumb_width = (u128::from(track_width) * u128::from(viewport_width)) + .div_ceil(u128::from(content_width)) + .clamp(1, u128::from(track_width)) as u16; + let travel = track_width.saturating_sub(thumb_width); + let maximum = content_width.saturating_sub(u64::from(viewport_width)); + if maximum == 0 || travel == 0 { + return (0, thumb_width); + } + let x = (u128::from(offset.min(maximum)) * u128::from(travel) + u128::from(maximum) / 2) + / u128::from(maximum); + (x as u16, thumb_width) +} + +/// Viewport offset represented by a cell position inside a track. +pub(crate) fn horizontal_offset_at( + content_width: u64, + viewport_width: u16, + track_width: u16, + position: u16, +) -> Option { + if content_width == 0 || viewport_width == 0 || track_width == 0 { + return None; + } + let maximum = content_width.saturating_sub(u64::from(viewport_width)); + let (_, thumb_width) = horizontal_thumb_geometry(content_width, viewport_width, 0, track_width); + let travel = track_width.saturating_sub(thumb_width); + if maximum == 0 || travel == 0 { + return Some(0); + } + // Treat the pointer as the thumb center so drag coordinates use the + // same travel range as horizontal_thumb_geometry. + let position = u128::from(position.min(track_width - 1)) + .saturating_sub(u128::from(thumb_width) / 2) + .min(u128::from(travel)); + let offset = (position * u128::from(maximum) + u128::from(travel) / 2) / u128::from(travel); + Some(offset as u64) +} + +/// Viewport offset produced by moving an anchored horizontal thumb. +pub(crate) fn horizontal_drag_offset( + content_width: u64, + viewport_width: u16, + track_width: u16, + anchor_offset: u64, + delta_x: i128, +) -> u64 { + let (_, thumb_width) = + horizontal_thumb_geometry(content_width, viewport_width, anchor_offset, track_width); + let travel = i128::from(track_width.saturating_sub(thumb_width).max(1)); + let maximum = i128::from(content_width.saturating_sub(u64::from(viewport_width))); + let delta = delta_x * maximum / travel; + (i128::from(anchor_offset) + delta).clamp(0, maximum) as u64 +} + +#[cfg(test)] +mod tests { + use super::{horizontal_drag_offset, horizontal_offset_at, horizontal_thumb_geometry}; + + #[test] + fn horizontal_thumb_tracks_the_viewport() { + assert_eq!(horizontal_thumb_geometry(0, 80, 0, 20), (0, 0)); + assert_eq!(horizontal_thumb_geometry(80, 80, 0, 20), (0, 20)); + assert_eq!(horizontal_thumb_geometry(120, 80, 0, 12), (0, 8)); + assert_eq!(horizontal_thumb_geometry(120, 80, 20, 12), (2, 8)); + assert_eq!(horizontal_thumb_geometry(120, 80, 40, 12), (4, 8)); + } + + #[test] + fn horizontal_track_positions_map_to_offsets() { + assert_eq!(horizontal_offset_at(0, 80, 10, 0), None); + assert_eq!(horizontal_offset_at(120, 80, 12, 4), Some(0)); + assert_eq!(horizontal_offset_at(120, 80, 12, 6), Some(20)); + assert_eq!(horizontal_offset_at(120, 80, 12, 8), Some(40)); + assert_eq!(horizontal_offset_at(120, 80, 12, 11), Some(40)); + } + + #[test] + fn horizontal_thumb_center_round_trips_to_its_offset() { + for offset in [0, 20, 40] { + let (thumb_x, thumb_width) = horizontal_thumb_geometry(120, 80, offset, 12); + assert_eq!(horizontal_offset_at(120, 80, 12, thumb_x + thumb_width / 2), Some(offset)); + } + } + + #[test] + fn horizontal_drag_preserves_its_anchor_and_covers_the_range() { + assert_eq!(horizontal_drag_offset(120, 80, 12, 40, 0), 40); + assert_eq!(horizontal_drag_offset(120, 80, 12, 0, 4), 40); + assert_eq!(horizontal_drag_offset(120, 80, 12, 40, -4), 0); + } + + #[test] + fn horizontal_scrollbar_reaches_content_past_u16_extent() { + let content_width = u64::from(u16::MAX) * 3; + let maximum = content_width - 80; + let (thumb_x, thumb_width) = horizontal_thumb_geometry(content_width, 80, maximum, 80); + + assert_eq!(thumb_x + thumb_width, 80); + assert_eq!( + horizontal_offset_at(content_width, 80, 80, thumb_x + thumb_width / 2), + Some(maximum) + ); + } +} diff --git a/cmux-tui/crates/cmux-tui/src/ui/terminal_grid.rs b/cmux-tui/crates/cmux-tui/src/ui/terminal_grid.rs index a58518318119..3f8575ff058b 100644 --- a/cmux-tui/crates/cmux-tui/src/ui/terminal_grid.rs +++ b/cmux-tui/crates/cmux-tui/src/ui/terminal_grid.rs @@ -1,5 +1,7 @@ +use std::borrow::Cow; + use cmux_tui_core::{Rect, SurfaceRenderFrame}; -use ghostty_vt::{Cell as VtCell, ColorSpec, Rgb}; +use ghostty_vt::{Cell as VtCell, CellWidth, ColorSpec, Rgb}; use ratatui::Frame; use ratatui::buffer::Buffer; use ratatui::layout::Rect as RatatuiRect; @@ -16,29 +18,70 @@ pub fn draw_render_frame( chrome: &ChromeTheme, selected: impl Fn(u16, u16) -> bool, ) -> Option<(u16, u16)> { - draw_render_frame_with_catalog(frame, rect, render, theme, chrome, catalog(), selected) + draw_render_frame_with_catalog( + frame, + HorizontalViewport { rect, source_x: 0 }, + render, + theme, + chrome, + catalog(), + selected, + ) +} + +pub fn draw_render_frame_cropped( + frame: &mut Frame, + rect: Rect, + source_x: u16, + render: &SurfaceRenderFrame, + theme: &Theme, + chrome: &ChromeTheme, + selected: impl Fn(u16, u16) -> bool, +) -> Option<(u16, u16)> { + draw_render_frame_with_catalog( + frame, + HorizontalViewport { rect, source_x }, + render, + theme, + chrome, + catalog(), + selected, + ) } -pub(crate) fn rendered_viewport_rect( +pub(crate) fn rendered_viewport_rect_cropped( rect: Rect, screen: RatatuiRect, render: &SurfaceRenderFrame, + source_x: u16, ) -> Rect { let max_cols = rect.width.min(screen.width.saturating_sub(rect.x)); let max_rows = rect.height.min(screen.height.saturating_sub(rect.y)); let (snap_cols, snap_rows) = render.frame.size; - Rect { x: rect.x, y: rect.y, width: snap_cols.min(max_cols), height: snap_rows.min(max_rows) } + Rect { + x: rect.x, + y: rect.y, + width: snap_cols.saturating_sub(source_x).min(max_cols), + height: snap_rows.min(max_rows), + } +} + +#[derive(Debug, Clone, Copy)] +struct HorizontalViewport { + rect: Rect, + source_x: u16, } fn draw_render_frame_with_catalog( frame: &mut Frame, - rect: Rect, + viewport: HorizontalViewport, render: &SurfaceRenderFrame, theme: &Theme, chrome: &ChromeTheme, catalog: &Catalog, selected: impl Fn(u16, u16) -> bool, ) -> Option<(u16, u16)> { + let HorizontalViewport { rect, source_x } = viewport; if rect.width == 0 || rect.height == 0 { return None; } @@ -46,7 +89,7 @@ fn draw_render_frame_with_catalog( let max_cols = rect.width.min(screen.width.saturating_sub(rect.x)) as usize; let max_rows = rect.height.min(screen.height.saturating_sub(rect.y)) as usize; let (snap_cols, snap_rows) = render.frame.size; - let live = rendered_viewport_rect(rect, screen, render); + let live = rendered_viewport_rect_cropped(rect, screen, render, source_x); let live_cols = usize::from(live.width); let live_rows = usize::from(live.height); let colors = PaletteResolver::from_frame(render); @@ -58,15 +101,20 @@ fn draw_render_frame_with_catalog( break; } let y = rect.y + row as u16; - for (col, cell) in cells.iter().enumerate() { - if col >= live_cols { - break; - } + let source_x = usize::from(source_x); + let available = cells.len().saturating_sub(source_x).min(live_cols); + let source_end = source_x.saturating_add(available); + for col in 0..available { + let source_col = source_x + col; let x = rect.x + col as u16; - let selected = selected(col as u16, row as u16); + let selected = selected(source_col as u16, row as u16); + let cell = &cells[source_col]; apply_cell(&mut buf[(x, y)], cell, &colors, selected.then_some(theme)); + if partial_wide_cell(cells, source_x, source_end, source_col) { + buf[(x, y)].set_symbol(" "); + } } - for col in cells.len()..live_cols { + for col in available..live_cols { let x = rect.x + col as u16; buf[(x, y)].set_symbol(" ").set_style(blank_style); } @@ -82,8 +130,33 @@ fn draw_render_frame_with_catalog( render .frame .cursor - .filter(|cursor| (cursor.x as usize) < live_cols && (cursor.y as usize) < live_rows) - .map(|cursor| (rect.x + cursor.x, rect.y + cursor.y)) + .filter(|cursor| { + cursor.x >= source_x + && usize::from(cursor.x - source_x) < live_cols + && (cursor.y as usize) < live_rows + }) + .map(|cursor| (rect.x + cursor.x - source_x, rect.y + cursor.y)) +} + +fn partial_wide_cell( + cells: &[VtCell], + source_start: usize, + source_end: usize, + source_col: usize, +) -> bool { + match cells[source_col].width { + CellWidth::Wide => cells + .get(source_col.saturating_add(1)) + .filter(|_| source_col.saturating_add(1) < source_end) + .is_none_or(|next| next.width != CellWidth::SpacerTail), + CellWidth::SpacerTail => { + source_col == source_start + || cells + .get(source_col.saturating_sub(1)) + .is_none_or(|previous| previous.width != CellWidth::Wide) + } + CellWidth::Narrow | CellWidth::SpacerHead => false, + } } #[allow(clippy::too_many_arguments)] @@ -284,11 +357,7 @@ fn apply_cell( selected: Option<&Theme>, ) { target.reset(); - if cell.text.is_empty() || cell.text.chars().any(char::is_control) { - target.set_symbol(" "); - } else { - target.set_symbol(&cell.text); - } + target.set_symbol(&renderable_cell_text(&cell.text)); let mut style = Style::default(); style = style.fg(colors.resolve_fg(cell.fg)); @@ -329,6 +398,17 @@ fn apply_cell( target.set_style(style); } +fn renderable_cell_text(text: &str) -> Cow<'_, str> { + if text.is_empty() { + return Cow::Borrowed(" "); + } + if !text.chars().any(char::is_control) { + return Cow::Borrowed(text); + } + let sanitized = text.chars().filter(|character| !character.is_control()).collect::(); + if sanitized.is_empty() { Cow::Borrowed(" ") } else { Cow::Owned(sanitized) } +} + #[cfg(test)] mod tests { use super::*; @@ -336,6 +416,13 @@ mod tests { use ratatui::Terminal as RatatuiTerminal; use ratatui::backend::TestBackend; + #[test] + fn terminal_cells_drop_control_characters_before_ratatui_diffing() { + assert_eq!(renderable_cell_text("\r").as_ref(), " "); + assert_eq!(renderable_cell_text("a\x1bb").as_ref(), "ab"); + assert_eq!(renderable_cell_text("plain").as_ref(), "plain"); + } + fn render_frame(cols: u16, rows: u16) -> SurfaceRenderFrame { let mut terminal = Terminal::new(cols, rows, 0, Callbacks::default()).unwrap(); terminal.vt_write(b"live"); @@ -362,7 +449,7 @@ mod tests { .draw(|frame| { draw_render_frame_with_catalog( frame, - rect, + HorizontalViewport { rect, source_x: 0 }, render, &Theme::default(), &chrome, @@ -374,6 +461,83 @@ mod tests { terminal } + #[test] + fn cropped_grid_starts_at_the_requested_source_column() { + let mut terminal = Terminal::new(8, 1, 0, Callbacks::default()).unwrap(); + terminal.vt_write(b"abcdefgh"); + let mut state = RenderState::new().unwrap(); + state.update(&mut terminal).unwrap(); + let render = SurfaceRenderFrame { + frame: state.build_frame().unwrap(), + scrollback_rows: 0, + palette_colors: std::array::from_fn(|idx| state.palette_color(idx as u8)), + palette_overridden: std::array::from_fn(|idx| state.palette_overridden(idx as u8)), + }; + let mut output = RatatuiTerminal::new(TestBackend::new(3, 1)).unwrap(); + output + .draw(|frame| { + draw_render_frame_with_catalog( + frame, + HorizontalViewport { + rect: Rect { x: 0, y: 0, width: 3, height: 1 }, + source_x: 3, + }, + &render, + &Theme::default(), + &ChromeTheme::dark(), + crate::localization::catalog_for_locale("en_US.UTF-8"), + |_, _| false, + ); + }) + .unwrap(); + + assert_eq!(row_text(output.backend().buffer(), 0, 0, 3), "def"); + } + + #[test] + fn cropped_grid_blanks_partial_wide_glyphs_at_both_edges() { + let mut terminal = Terminal::new(6, 1, 0, Callbacks::default()).unwrap(); + terminal.vt_write("a界bc".as_bytes()); + let mut state = RenderState::new().unwrap(); + state.update(&mut terminal).unwrap(); + let render = SurfaceRenderFrame { + frame: state.build_frame().unwrap(), + scrollback_rows: 0, + palette_colors: std::array::from_fn(|idx| state.palette_color(idx as u8)), + palette_overridden: std::array::from_fn(|idx| state.palette_overridden(idx as u8)), + }; + let draw_crop = |source_x| { + let mut output = RatatuiTerminal::new(TestBackend::new(2, 1)).unwrap(); + output + .draw(|frame| { + draw_render_frame_with_catalog( + frame, + HorizontalViewport { + rect: Rect { x: 0, y: 0, width: 2, height: 1 }, + source_x, + }, + &render, + &Theme::default(), + &ChromeTheme::dark(), + crate::localization::catalog_for_locale("en_US.UTF-8"), + |_, _| false, + ); + }) + .unwrap(); + output + }; + + let clipped_lead = draw_crop(0); + assert_eq!(row_text(clipped_lead.backend().buffer(), 0, 0, 2), "a "); + + let complete_glyph = draw_crop(1); + assert_eq!(complete_glyph.backend().buffer()[(0, 0)].symbol(), "界"); + assert_eq!(complete_glyph.backend().buffer()[(1, 0)].symbol(), " "); + + let clipped_tail = draw_crop(2); + assert_eq!(row_text(clipped_tail.backend().buffer(), 0, 0, 2), " b"); + } + fn row_text(buffer: &Buffer, y: u16, x: u16, width: u16) -> String { (x..x + width).map(|cell_x| buffer[(cell_x, y)].symbol()).collect() } diff --git a/cmux-tui/crates/cmux-tui/tests/cli.rs b/cmux-tui/crates/cmux-tui/tests/cli.rs index b198fa77f788..9f64057a01d8 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli.rs @@ -364,6 +364,26 @@ fn machine_agent_argument_failures_are_stable_and_localized() { assert!(!stderr.contains("machine-agent を開始または続行できませんでした")); } +#[test] +fn ratio_commands_localize_nonfinite_values() { + for args in [ + ["set-ratio", "--pane", "1", "--dir", "right", "--ratio", "NaN"].as_slice(), + ["set-split-ratio", "--split", "1", "--ratio", "NaN"].as_slice(), + ] { + let output = Command::new(bin()) + .env("LC_ALL", "ja_JP.UTF-8") + .env("LC_MESSAGES", "ja_JP.UTF-8") + .env("LANG", "ja_JP.UTF-8") + .args(args) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("--ratio には有限の数値を指定してください"), "{stderr}"); + assert!(!stderr.contains("--ratio must be a finite number"), "{stderr}"); + } +} + #[cfg(unix)] struct PtyChild { child: Child, @@ -422,6 +442,60 @@ impl Drop for PtyChild { } } +#[cfg(unix)] +struct DisconnectablePtyChild { + child: Child, + master: Option, +} + +#[cfg(unix)] +impl DisconnectablePtyChild { + fn start(args: &[&str]) -> Self { + let mut master = -1; + let mut slave = -1; + let mut size = libc::winsize { ws_row: 24, ws_col: 80, ws_xpixel: 0, ws_ypixel: 0 }; + let opened = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + &raw mut size, + ) + }; + assert_eq!(opened, 0, "openpty failed: {}", std::io::Error::last_os_error()); + let flags = unsafe { libc::fcntl(master, libc::F_GETFD) }; + assert_ne!(flags, -1, "fcntl(F_GETFD) failed: {}", std::io::Error::last_os_error()); + let cloexec = unsafe { libc::fcntl(master, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + assert_ne!(cloexec, -1, "fcntl(F_SETFD) failed: {}", std::io::Error::last_os_error()); + + let master = unsafe { File::from_raw_fd(master) }; + let slave = unsafe { File::from_raw_fd(slave) }; + let child = Command::new(bin()) + .args(args) + .env_remove("CMUX_TUI_SOCKET") + .stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from(slave.try_clone().unwrap())) + .stderr(Stdio::from(slave)) + .spawn() + .unwrap(); + Self { child, master: Some(master) } + } + + fn disconnect_host_terminal(&mut self) { + self.master.take(); + } +} + +#[cfg(unix)] +impl Drop for DisconnectablePtyChild { + fn drop(&mut self) { + self.master.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + #[cfg(unix)] #[test] fn startup_config_helper_inherits_no_provider_secrets() { @@ -496,6 +570,53 @@ fn plain_launch_attaches_to_existing_local_session() { panic!("plain launch never attached as a TUI client"); } +#[cfg(unix)] +#[test] +fn host_terminal_disconnect_exits_frontend_without_stopping_server() { + let server = HeadlessServer::start("host-terminal-disconnect"); + let mut tui = DisconnectablePtyChild::start(&["--socket", server.socket.to_str().unwrap()]); + let attach_deadline = Instant::now() + Duration::from_secs(10); + let mut attached = false; + + while Instant::now() < attach_deadline { + if let Some(status) = tui.child.try_wait().unwrap() { + panic!("plain launch exited before host disconnect: {status}"); + } + let clients = cli(&server, &["--json", "list-clients"]); + if clients.status.success() { + let clients: serde_json::Value = serde_json::from_slice(&clients.stdout).unwrap(); + if clients + .as_array() + .unwrap() + .iter() + .any(|client| client["kind"].as_str() == Some("tui")) + { + attached = true; + break; + } + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!(attached, "plain launch never attached before host disconnect"); + + tui.disconnect_host_terminal(); + let exit_deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = tui.child.try_wait().unwrap() { + break status; + } + assert!( + Instant::now() < exit_deadline, + "frontend remained alive after its host terminal disconnected" + ); + std::thread::sleep(Duration::from_millis(25)); + }; + assert!(!status.success(), "host terminal disconnect unexpectedly reported success"); + + let ping = cli(&server, &["--json", "ping"]); + assert_success(&ping); +} + #[cfg(unix)] #[test] fn surface_attach_uses_the_full_terminal_and_attaches_only_its_target() { @@ -784,6 +905,14 @@ fn cli_verbs_cover_command_output_errors_and_streams() { let tree = cli(&server, &["--json", "list-workspaces"]); assert_success(&tree); let tree_json: serde_json::Value = serde_json::from_slice(&tree.stdout).unwrap(); + assert!( + tree_json["workspaces"][0]["screens"][0].get("viewport_base_width").is_none(), + "ordinary layouts must preserve the default screen JSON shape" + ); + assert!( + tree_json["workspaces"][0]["screens"][0].get("viewport_splits").is_none(), + "ordinary layouts must omit viewport-only split metadata" + ); let pane0 = tree_json["workspaces"][0]["screens"][0]["panes"][0]["id"].as_u64().unwrap(); let split = cli(&server, &["split", "--pane", &pane0.to_string(), "--dir", "right"]); @@ -801,6 +930,14 @@ fn cli_verbs_cover_command_output_errors_and_streams() { let exported_json: serde_json::Value = serde_json::from_slice(&exported.stdout).unwrap(); assert_eq!(exported_json["layout"]["type"].as_str(), Some("split")); assert_eq!(exported_json["panes"].as_array().unwrap().len(), 3); + assert!( + exported_json.get("viewport_base_width").is_none(), + "ordinary layout exports must omit viewport-only state" + ); + assert!( + exported_json.get("viewport_splits").is_none(), + "ordinary layout exports must omit viewport-only split metadata" + ); let split_id = exported_json["layout"]["split"].as_u64().unwrap(); let exact_ratio = @@ -840,6 +977,38 @@ fn cli_verbs_cover_command_output_errors_and_streams() { assert_eq!(zoom_json["zoomed"].as_bool(), Some(true)); assert_eq!(zoom_json["zoomed_pane"].as_u64(), Some(pane1)); + let viewport_pane = cli( + &server, + &["new-pane-right", "--pane", &pane1.to_string(), "--cols", "51", "--rows", "22"], + ); + assert_success(&viewport_pane); + let viewport_surface = + String::from_utf8(viewport_pane.stdout).unwrap().trim().parse::().unwrap(); + assert!(viewport_surface > 0); + let tree = cli(&server, &["--json", "list-workspaces"]); + assert_success(&tree); + let tree: serde_json::Value = serde_json::from_slice(&tree.stdout).unwrap(); + let viewport_splits = + tree["workspaces"][0]["screens"][0]["viewport_splits"].as_array().unwrap(); + assert_eq!(viewport_splits.len(), 1); + let width = viewport_splits[0]["width"].as_f64().unwrap(); + assert!((width - 2.0 / 3.0).abs() < 0.0001); + let viewport_pane = tree["workspaces"][0]["screens"][0]["active_pane"].as_u64().unwrap(); + let resize_viewport = cli( + &server, + &["set-viewport-pane-width", "--pane", &viewport_pane.to_string(), "--width", "0.5"], + ); + assert_success(&resize_viewport); + let resize_base = + cli(&server, &["set-viewport-pane-width", "--pane", &pane0.to_string(), "--width", "0.75"]); + assert_success(&resize_base); + let tree = cli(&server, &["--json", "list-workspaces"]); + assert_success(&tree); + let tree: serde_json::Value = serde_json::from_slice(&tree.stdout).unwrap(); + let screen = &tree["workspaces"][0]["screens"][0]; + assert_eq!(screen["viewport_base_width"].as_f64(), Some(0.75)); + assert_eq!(screen["viewport_splits"][0]["width"].as_f64(), Some(0.5)); + let marker = format!("cmux_cli_marker_{}", std::process::id()); let send = cli( &server, @@ -1071,6 +1240,7 @@ fn help_lists_plugin_verbs() { assert!(stdout.contains("--ws ")); assert!(stdout.contains("--ws-token ")); assert!(stdout.contains("--ws-insecure-bind")); + assert!(stdout.contains("new-pane-right")); } #[cfg(unix)] diff --git a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs index 8a4178bc1156..a6dd402ab3b1 100644 --- a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs +++ b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs @@ -19,7 +19,7 @@ use cmux_tui_core::terminal_host_protocol::{ PROTOCOL_VERSION, ProtocolError, RESIZE_ACK_CANONICAL_CHANGED, read_frame, write_frame, }; use cmux_tui_core::terminal_host_runtime::{ - TerminalHostLiveness, adopt_terminal_host, decode_terminal_color_overrides, + TerminalHostLiveness, TerminalHostRecord, adopt_terminal_host, decode_terminal_color_overrides, load_terminal_host_records, remove_stale_terminal_host_record, terminal_host_record_liveness, terminal_host_root, }; @@ -398,16 +398,7 @@ fn new_host_rolls_back_when_surface_setup_fails_after_connect() { let response = request_thread.join().unwrap(); assert_eq!(response["ok"], false); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - if terminal_host_record_liveness(&record_path, &record).unwrap() - == TerminalHostLiveness::Dead - { - break; - } - assert!(Instant::now() < deadline, "rolled-back host remained alive"); - std::thread::sleep(Duration::from_millis(20)); - } + wait_for_terminal_host_dead(&record_path, &record); wait_for_no_host_records(&harness.host_root()); wait_for_process_and_group_absent(shell_pid); } @@ -439,10 +430,7 @@ fn explicit_terminate_escalates_past_a_sighup_ignoring_child() { host.terminate().unwrap(); host.disconnect(); wait_for_no_host_records(&harness.host_root()); - assert_eq!( - terminal_host_record_liveness(&record_path, &record).unwrap(), - TerminalHostLiveness::Dead, - ); + wait_for_terminal_host_dead(&record_path, &record); wait_for_process_and_group_absent(shell_pid); } @@ -498,10 +486,7 @@ fn explicit_terminate_reaps_descendants_in_the_pty_group() { host.terminate().unwrap(); host.disconnect(); wait_for_no_host_records(&harness.host_root()); - assert_eq!( - terminal_host_record_liveness(&record_path, &record).unwrap(), - TerminalHostLiveness::Dead, - ); + wait_for_terminal_host_dead(&record_path, &record); wait_for_process_and_group_absent(direct_pid); wait_for_process_and_group_absent(descendant_pid); } @@ -1690,7 +1675,12 @@ fn stalled_renderer_is_disconnected_without_freezing_the_host() { "cmd": "send", "surface": surface, "text": format!( - "/usr/bin/head -c 20000000 /dev/zero; printf '{done}\\n'\n" + // Exceed both the host's bounded client queue and Darwin's + // dynamically sized Unix-socket buffers. A smaller burst can + // fit entirely in the kernel under light load, which does not + // represent a stalled writer and made this assertion timing + // dependent. + "/usr/bin/head -c 64000000 /dev/zero; printf '{done}\\n'\n" ), }), ); @@ -2437,10 +2427,7 @@ fn wait_for_screen(path: &Path, surface: u64, marker: &str) -> String { last } -fn wait_for_host_records( - root: &Path, - expected: usize, -) -> Vec<(PathBuf, cmux_tui_core::terminal_host_runtime::TerminalHostRecord)> { +fn wait_for_host_records(root: &Path, expected: usize) -> Vec<(PathBuf, TerminalHostRecord)> { let deadline = Instant::now() + Duration::from_secs(10); loop { let records = load_terminal_host_records(root).unwrap(); @@ -2463,6 +2450,17 @@ fn wait_for_no_host_records(root: &Path) { panic!("terminal host record was not removed after close"); } +fn wait_for_terminal_host_dead(path: &Path, record: &TerminalHostRecord) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if terminal_host_record_liveness(path, record).unwrap() == TerminalHostLiveness::Dead { + return; + } + assert!(Instant::now() < deadline, "terminal host remained alive after termination"); + std::thread::sleep(Duration::from_millis(20)); + } +} + fn wait_for_pid_file(path: &Path) -> libc::pid_t { let deadline = Instant::now() + Duration::from_secs(10); loop { @@ -2571,7 +2569,7 @@ fn attach_state(path: &Path, surface: u64) -> serde_json::Value { } fn wait_for_host_cursor_snapshot( - record: &cmux_tui_core::terminal_host_runtime::TerminalHostRecord, + record: &TerminalHostRecord, style: ghostty_vt::CursorShape, blink: bool, ) { diff --git a/cmux-tui/docs/concepts.md b/cmux-tui/docs/concepts.md index 1b0e0ff232ce..873a2e395781 100644 --- a/cmux-tui/docs/concepts.md +++ b/cmux-tui/docs/concepts.md @@ -8,7 +8,7 @@ The mux tree is: session -> workspaces -> screens -> split-tree panes -> tabs ``` -A session is one mux backend and one control socket. A workspace owns zero or more screens. A screen is the layout selected in the status bar. A screen layout is a binary split tree whose leaves are panes. A pane owns an ordered tab list, and each tab is a surface. +A session is one mux backend and one control socket. A workspace owns zero or more screens. A screen is the layout selected in the status bar. A normal screen owns one binary split tree whose leaves are panes. A horizontally scrollable screen owns an ordered list of stable columns, and each column owns its own split tree. The server projects those columns into the existing split-tree protocol shape for compatibility. A pane owns an ordered tab list, and each tab is a surface. Protocol v8 assigns each interior split node a stable `SplitId`. Frontends use it as divider identity and resize that exact node with `set-split-ratio`. The id survives ratio, focus, tab, and leaf-order changes. It disappears only when its node collapses. @@ -30,11 +30,17 @@ Tabs are surfaces. A PTY tab wraps a child process connected to a pseudo-termina Pane names still exist in the control socket through `rename-pane`. They are separate from the tab labels shown in the TUI. -## Smart Split +## Automatic Layout -The modeless `Alt-n` binding creates a new pane with smart split direction. The TUI first tries the focused pane. If that pane cannot split in the chosen direction, it tries the largest pane that can. +The modeless `Alt-n` binding creates a new pane and reapplies Zellij's default distribution inside the focused horizontal column. Each column preserves its own pane creation order across swaps and manual splits. A screen without horizontal columns is one implicit column, so the default behavior is unchanged. -Direction follows a zellij-style rule using the terminal cell ratio. Tall enough panes split down. Wide enough panes split right. Panes below the configured size thresholds do not split. +## Viewport Panes + +`Ctrl-b g` creates a terminal immediately after the horizontal column containing the focused pane. Its default width is two-thirds of each frontend's viewport. Supporting frontends retain each column's independent width and expose overflow through a horizontal scrollbar. Ordinary split and startup behavior remain tiled. + +## Layout Undo + +Each screen keeps an in-memory history of its latest structural layout actions. `Ctrl-b U` undoes the newest entry on the focused screen. Repeated changes to one divider are coalesced. Undoing pane creation requires confirmation because it closes the pane's live surfaces. The confirmation carries the exact layout revision, so a later layout action makes an older prompt fail without closing anything. A direct pane close clears the history because the mux cannot recreate the closed process. ## Collapse Behavior diff --git a/cmux-tui/docs/configuration.md b/cmux-tui/docs/configuration.md index 3beaac6522ef..34e9c4ca5b75 100644 --- a/cmux-tui/docs/configuration.md +++ b/cmux-tui/docs/configuration.md @@ -191,6 +191,14 @@ Chrome 136 and newer reject CDP remote debugging on the OS-default profile direc Terminal panes, the workspace sidebar, and the shortcut modal share the same `▕` thumb, which expands to `▐` while hovered or dragged. A scrollbar is drawn only when its content exceeds the visible rows. +## Viewport + +| Key | Type | Default | Effect | +| --- | --- | --- | --- | +| `viewport.animation` | boolean | `true` | Animate horizontal viewport movement | + +`Ctrl-b g` inserts a terminal immediately after the focused horizontal column at two-thirds of the current viewport width. Existing panes retain their tiled layout. The status bar gains a continuous horizontal track whenever the resulting screen is wider than the viewport. Focus movement and track clicks reveal offscreen panes. `Alt-n` applies automatic layout inside the focused column. Set `{"viewport":{"animation":false}}` to make viewport moves immediate. + ## Server | Key | Type | Default | Effect | @@ -236,6 +244,8 @@ WebSocket clients pair through a six-digit browser/TUI comparison by default. We | `keys.toggle-sidebar-compact` | chord string or array or `"none"` | `"m"` | Toggle compact/full sidebar width and show the sidebar | | `keys.toggle-sidebar-view` | chord string or array or `"none"` | `"e"` | Toggle the built-in files/workspaces view; a plugin still takes precedence | | `keys.focus-sidebar` | chord string or array or `"none"` | `"S"` | Focus the built-in sidebar or sidebar plugin; a prefixed command returns focus to the pane | +| `keys.new-pane-right` | chord string or array or `"none"` | `"g"` | Insert a two-thirds-width terminal after the focused horizontal column | +| `keys.undo-layout` | chord string or array or `"none"` | `"U"` | Undo the latest structural layout action on the focused screen | | `keys.focus-next-pane` | chord string or array or `"none"` | `"o"` | Cycle to the next pane in the current screen | | `keys.focus-left` | chord string or array or `"none"` | `["h","left","alt+h","alt+left"]` | Focus left | | `keys.focus-right` | chord string or array or `"none"` | `["l","right","alt+l","alt+right"]` | Focus right | @@ -327,6 +337,9 @@ Chord strings can be single characters or a key name with optional `ctrl`, `cont "scrollbar": { "position": "column" }, + "viewport": { + "animation": true + }, "server": { "ws": "127.0.0.1:7681", "ws_token": "replace-with-a-secret" diff --git a/cmux-tui/docs/keyboard.md b/cmux-tui/docs/keyboard.md index 04dcb18c6181..06bc70c42458 100644 --- a/cmux-tui/docs/keyboard.md +++ b/cmux-tui/docs/keyboard.md @@ -18,7 +18,7 @@ These defaults come from `Keys::default`. | `Ctrl-b t` | New PTY tab in the active pane | | `Alt-t` | New PTY tab in the active pane | | `Ctrl-b B` | Open the browser-tab URL prompt | -| `Alt-n` | Create a pane with Zellij's default vertical auto-layout | +| `Alt-n` | Create a pane with Zellij's default auto-layout in the focused horizontal column | | `Ctrl-b Tab` | Next tab in the active pane | | `Ctrl-b BackTab` | Previous tab in the active pane | | `Ctrl-b 0` through `Ctrl-b 9` | Select visible screen 0 through 9 | @@ -48,6 +48,8 @@ These defaults come from `Keys::default`. | `Ctrl-b m` | Toggle the sidebar between compact and full width; shows it when hidden | | `Ctrl-b e` | Toggle the built-in sidebar between files and workspaces | | `Ctrl-b S` | Focus the built-in sidebar or configured sidebar plugin; a prefixed command returns focus to the pane | +| `Ctrl-b g` | Append a two-thirds-width terminal to the right | +| `Ctrl-b U` | Undo the latest structural layout action on the focused screen | | `Ctrl-b ?` | Open the keyboard shortcut modal | | `Ctrl-b h` or `Ctrl-b Left` | Focus left | | `Alt-h` or `Alt-Left` | Focus left | @@ -57,8 +59,8 @@ These defaults come from `Keys::default`. | `Alt-k` or `Alt-Up` | Focus up | | `Ctrl-b j` or `Ctrl-b Down` | Focus down | | `Alt-j` or `Alt-Down` | Focus down | -| `Alt-=` | Grow the focused split | -| `Alt--` | Shrink the focused split | +| `Alt-=` | Grow the focused split, or its horizontal viewport column | +| `Alt--` | Shrink the focused split, or its horizontal viewport column | | `Ctrl-b [` | Scroll the active PTY viewport up 10 rows | | `Ctrl-b PageUp` | Scroll the active PTY viewport up 10 rows | | `Ctrl-b PageDown` | Scroll the active PTY viewport down 10 rows | @@ -67,6 +69,12 @@ These defaults come from `Keys::default`. Directional focus follows Zellij's pane memory: when several panes share the requested edge, cmux-tui returns to the pane focused most recently. +When a screen is wider than the viewport, `h`/`l`, Left/Right, and their modeless Alt bindings reveal the focused pane. The viewport movement is animated unless `viewport.animation` is false. + +On a screen created with `Ctrl-b g`, `Alt-=` and `Alt--` resize the complete horizontal column containing the focused pane in five-percent steps. On an ordinary screen they retain their existing split-resize behavior. + +`Ctrl-b U` undoes the latest pane creation, split resize, column resize, swap, zoom, or automatic-layout change on the focused screen. Repeated resize updates to one divider form one undo entry. An undo that closes a created pane opens a confirmation prompt; type `CONFIRM` to close its PTY and commit the undo. Closing a pane directly clears that screen's undo history because a closed process cannot be restored. + On a primary screen, `Cmd-k` clears retained scrollback and completed visible rows inside the terminal emulator. OSC 133 prompt metadata preserves the complete active prompt. Without metadata, cmux-tui preserves visible rows because it cannot identify the active input boundary. The edit buffer and cursor stay in place, and cmux-tui sends no input to the shell. In alternate-screen applications, `Cmd-k` is forwarded to the application. `Ctrl-l` remains child-owned on every screen, so shells, REPLs, and other terminal applications keep their standard clear/redraw behavior and custom bindings. The screen bindings intentionally match tmux: `c` creates a screen, `n` and `p` switch screens, `&` closes a screen, `,` renames a screen, `z` zooms a pane, `o` cycles panes, `{` and `}` swap panes, and number keys select visible screens. Screens are numbered from 0, so `Ctrl-b 0` selects screen 0 and `Ctrl-b 1` selects screen 1. @@ -87,7 +95,7 @@ When the optional machine rail is visible, `Ctrl-b S` still enters through the w ## Modeless Alt Layer -Any configured Alt chord is active without the prefix. Default modeless commands are `Alt-t`, `Alt-n`, `Alt-[`, `Alt-]`, `Alt-{`, `Alt-}`, `Alt-h/j/k/l`, Alt arrows, `Alt-=`, and `Alt--`. `Alt-n` follows Zellij's default auto-layout sequence: one full-height left pane and up to four right-side rows, balanced columns of four through twelve panes, then one full-height left pane beside a right-side stack with the focused stack pane expanded. +Any configured Alt chord is active without the prefix. Default modeless commands are `Alt-t`, `Alt-n`, `Alt-[`, `Alt-]`, `Alt-{`, `Alt-}`, `Alt-h/j/k/l`, Alt arrows, `Alt-=`, and `Alt--`. `Alt-n` follows Zellij's default auto-layout sequence inside the focused horizontal column: one full-height left pane and up to four right-side rows, balanced columns of four through twelve panes, then one full-height left pane beside a right-side stack with the focused stack pane expanded. A screen without horizontal columns is one implicit column, preserving the previous whole-screen behavior. Set `keys.alt_shortcuts` to `false` to remove the default Alt bindings. This kill switch only removes defaults; Alt chords explicitly configured in `cmux-tui.json` still work. @@ -183,6 +191,8 @@ toggle-sidebar toggle-sidebar-compact toggle-sidebar-view focus-sidebar +new-pane-right +undo-layout focus-left focus-right focus-up diff --git a/cmux-tui/docs/mouse.md b/cmux-tui/docs/mouse.md index 1e50e78180e3..097f00723a2e 100644 --- a/cmux-tui/docs/mouse.md +++ b/cmux-tui/docs/mouse.md @@ -12,6 +12,8 @@ Each pane has a border box. Click inside a pane to focus it. The top border is t The status bar lists screens for the active workspace. Click a screen segment to select it. Click the trailing `+` to create a screen. +When a screen overflows horizontally, the open space in the status bar becomes a continuous viewport track. Click it to animate to that position, or drag it for direct movement. Horizontal wheel events over the pane region pan by one sixth of the viewport. Screens without horizontal overflow continue forwarding those events to the active surface. + A workspace-row or tab-chip press arms the stable identity from the hit target that was actually rendered. Its matching release completes that same action even if another frontend changes selection and triggers a routing refresh @@ -35,7 +37,9 @@ Wheel over a PTY pane focuses that pane first. When the inner app enables termin ## Resize -Drag pane borders to resize the matching split. Dragging a corner adjusts both intersecting split axes. The ratio is clamped from 0.05 to 0.95. Outer edges that do not correspond to a split do not change layout. +Drag pane borders to resize the matching split. Dragging a corner adjusts both intersecting split axes. Ordinary split ratios are clamped from 0.05 to 0.95. Outer edges that do not correspond to a split do not change layout. + +On a horizontally scrollable screen, drag either side of a column divider to resize the column on its left. Drag the final column's right border to resize that column. Column widths are clamped from one tenth through one full viewport. Splits inside each column remain independently resizable. One continuous divider drag is coalesced into one `Ctrl-b U` layout-undo entry. Drag a rail border to resize that rail for the current TUI session. Dragging the workspace rail leaves compact mode and sets its full-width override. The configured base widths come from `machine_sidebar.width`, `sidebar.width`, and `sidebar.compact_width`; each rail honors its own `max_width`. With both rails visible, resizing one preserves the other rail's width while leaving at least 40 columns for pane content. diff --git a/cmux-tui/docs/protocol.md b/cmux-tui/docs/protocol.md index 1c3958b2f35d..797f8911db8b 100644 --- a/cmux-tui/docs/protocol.md +++ b/cmux-tui/docs/protocol.md @@ -16,7 +16,7 @@ $TMPDIR/cmux-tui-/.sock ```json {"id":1,"cmd":"identify"} -{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"...","protocol":10,"capabilities":["attach-initial-size","workspace-registry-v1","clear-history-v1","clear-history-key-v1","provider-managed-workspace-authority-v2"],"session":"main","pid":12345}} +{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"...","protocol":10,"capabilities":["attach-initial-size","workspace-registry-v1","viewport-splits-v1","viewport-column-resize-v1","layout-undo-v1","clear-history-v1","surface-subscribe-filter","provider-managed-workspace-authority-v2","clear-history-key-v1"],"session":"main","pid":12345}} ``` Responses have this shape. The second example is a failed `clear-history` request: @@ -44,6 +44,12 @@ Failed `clear-history` responses add `error_delivery`. `known-not-delivered` pro `provider-managed-workspace-authority-v2` means the mux was provider-locked before its first control client and accepts private mirror commits only with its pre-provisioned authority. `mark-workspaces-provider-managed` validates that authority without changing ownership. Ordinary `close-workspace` and `rename-workspace` requests always fail on that mux. The provider-aware TUI sends an authorized `close-provider-managed-workspace` or `rename-provider-managed-workspace` only after the external provider accepts the corresponding lifecycle request. Provider-aware clients must refuse provider-owned mode when the server does not advertise this capability. +`viewport-splits-v1` adds `new-pane-right` and a `viewport_splits` array to screen snapshots that use horizontal viewport columns. Each entry identifies a stable split id and gives the right child's width as a fraction of the frontend viewport. Frontends that implement it render the existing tree at one viewport width, append the marked right child, and expose horizontal viewport movement. Ordinary screen snapshots omit this viewport-only metadata. Other clients can ignore it and use the split ratio. + +`viewport-column-resize-v1` adds `set-viewport-pane-width` and `viewport_base_width`. Widths remain frontend-relative, from 0.1 through 1.0. Clients must require this capability before sending the resize command. An older server still renders the fallback split ratios and rejects the unknown command without changing layout. Invalid widths return `error_code:"viewport-width-out-of-range"`; a missing or ordinary pane returns `error_code:"viewport-column-not-found"`. + +`layout-undo-v1` adds server-owned structural layout history and `undo-layout`. A creation undo first returns `confirmation_required`, the pane ids it would close, and a unique confirmation revision bound to those panes' exact tab membership. The client must show that consequence and resend the exact revision with `confirm_close:true`. A stale revision or changed tab membership fails without closing a pane; request a new preview before retrying. Resize-only and other non-destructive entries undo in one request. + `move-tab` moves a surface to a target pane and insertion index. It supports same-pane reorder and cross-pane moves. ```json @@ -64,6 +70,8 @@ Protocol-v8 split nodes serialize as `{type:"split",split:,dir,ratio,a,b}`. {"id":12,"cmd":"set-split-ratio","split":9,"ratio":0.65} ``` +Ratios are clamped to `0.05..0.95`. A live split in a horizontal viewport can still imply a column width outside the supported `0.1..1.0` range. The server rejects that request with `error_code:"layout-ratio-out-of-range"` and keeps the split and layout unchanged; `layout-ratio-target-missing` is reserved for an absent pane or split. + ## Events `subscribe` starts event streaming: diff --git a/cmux-tui/scripts/smoke-attach.py b/cmux-tui/scripts/smoke-attach.py index c4503baadd7b..7649332ac121 100644 --- a/cmux-tui/scripts/smoke-attach.py +++ b/cmux-tui/scripts/smoke-attach.py @@ -74,6 +74,31 @@ def wait_for_control_socket(server, seconds=15): ) +def control_string_end(data, start, allow_bel): + ends = [] + if allow_bel: + bel = data.find(b"\x07", start) + if bel >= 0: + ends.append((bel, bel + 1)) + st = data.find(b"\x1b\\", start) + if st >= 0: + ends.append((st, st + 2)) + if not ends: + return None + return min(ends, key=lambda entry: entry[0])[1] + + +def non_csi_escape_end(data, start): + if start + 1 >= len(data): + return None + kind = data[start + 1] + if kind == ord("]"): + return control_string_end(data, start + 2, allow_bel=True) + if kind in (ord("P"), ord("X"), ord("^"), ord("_")): + return control_string_end(data, start + 2, allow_bel=False) + return start + 2 + + def render_client_frame(data, rows=30, cols=100): chars = [[" " for _ in range(cols)] for _ in range(rows)] reverse = [[False for _ in range(cols)] for _ in range(rows)] @@ -165,6 +190,12 @@ def render_client_frame(data, rows=30, cols=100): rev = False i = j + 1 continue + if b == 0x1B: + end = non_csi_escape_end(data, i) + if end is None: + break + i = end + continue if b == 0x0D: x = 0 i += 1 @@ -199,6 +230,14 @@ def render_client_frame(data, rows=30, cols=100): return chars, reverse +control_frame, _ = render_client_frame( + b"\x1b]12;#c0c1b5\x07A\x1bPignored payload\x1b\\B", + rows=1, + cols=2, +) +assert control_frame[0] == ["A", "B"], control_frame[0] + + def reverse_percent_cells(frame): chars, reverse = frame rows = len(chars) @@ -379,6 +418,15 @@ def wait_output(self, needle, seconds): return True return False + def wait_frame_text(self, needle, seconds): + deadline = time.time() + seconds + while time.time() < deadline: + self.drain(0.2) + frame, _ = render_client_frame(self.output, self.rows, self.cols) + if needle in "\n".join("".join(row) for row in frame): + return True + return False + def send(self, data): os.write(self.fd, data) @@ -431,7 +479,15 @@ def detach(self): assert c2.wait_output(MARKER, 15), "marker not rendered after reattach" # Live path still works after replay: type another command. c2.send(b"printf '\\033[3J\\033[H\\033[2J'; printf 'live-after-reattach\\n'\r") - assert c2.wait_output("live-after-reattach", 15), "live stream broken after reattach" + if not c2.wait_frame_text("live-after-reattach", 15): + stalled_screen = rpc({"id": 30, "cmd": "read-screen", "surface": surface_id}) + stalled_frame, _ = render_client_frame(c2.output) + raise AssertionError( + "live stream broken after reattach; client frame:\n" + + "\n".join("".join(row) for row in stalled_frame) + + "\nserver screen:\n" + + stalled_screen["data"]["text"] + ) live_screen = rpc({"id": 3, "cmd": "read-screen", "surface": surface_id}) assert "live-after-reattach" in live_screen["data"]["text"], live_screen["data"]["text"] diff --git a/cmux-tui/scripts/smoke-tui.py b/cmux-tui/scripts/smoke-tui.py index f26157d539c7..c5e0cf9d0237 100644 --- a/cmux-tui/scripts/smoke-tui.py +++ b/cmux-tui/scripts/smoke-tui.py @@ -551,9 +551,8 @@ def assert_snapshot_parser_controls(): assert "example.com" in text, text[-800:] os.write(fd, b"\x1b") drain(0.5) -# Close the browser TAB. prefix-X since the tmux-alignment flip: x kills the -# pane (which here is the only pane and would end the session), X the tab. -os.write(fd, b"\x02X") +# Close the browser tab without closing its containing pane. +os.write(fd, b"\x02x") drain(0.8) screen0 = active_screen(tree()[0]) assert len(screen0["panes"][0]["tabs"]) == before_tabs, screen0 @@ -839,20 +838,21 @@ def assert_snapshot_parser_controls(): assert "├" in text, text[-800:] assert "[ OK ⏎ ]" not in text, text[-800:] menu_lines = render_text_snapshot(output).splitlines() -assert "Rename tab" in menu_lines[5], menu_lines[4:18] -assert "Close tab" in menu_lines[6], menu_lines[4:18] -assert "├" in menu_lines[7], menu_lines[4:18] -assert "New tab" in menu_lines[8], menu_lines[4:18] -assert "New browser tab" in menu_lines[9], menu_lines[4:18] -assert "├" in menu_lines[10], menu_lines[4:18] -assert "Split right" in menu_lines[11], menu_lines[4:18] -assert "Split down" in menu_lines[12], menu_lines[4:18] -assert "Close pane" in menu_lines[13], menu_lines[4:18] -assert "├" in menu_lines[14], menu_lines[4:18] -assert "Copy tab id" in menu_lines[15], menu_lines[4:18] -assert "Copy pane id" in menu_lines[16], menu_lines[4:18] +assert "Rename tab" in menu_lines[5], menu_lines[4:19] +assert "Close tab" in menu_lines[6], menu_lines[4:19] +assert "├" in menu_lines[7], menu_lines[4:19] +assert "New pane" in menu_lines[8], menu_lines[4:19] +assert "New tab" in menu_lines[9], menu_lines[4:19] +assert "New browser tab" in menu_lines[10], menu_lines[4:19] +assert "├" in menu_lines[11], menu_lines[4:19] +assert "Split right" in menu_lines[12], menu_lines[4:19] +assert "Split down" in menu_lines[13], menu_lines[4:19] +assert "Close pane" in menu_lines[14], menu_lines[4:19] +assert "├" in menu_lines[15], menu_lines[4:19] +assert "Copy tab id" in menu_lines[16], menu_lines[4:19] +assert "Copy pane id" in menu_lines[17], menu_lines[4:19] output = b"" -os.write(fd, b"\x1b[<34;81;16M\x1b[<2;81;16m") +os.write(fd, b"\x1b[<34;81;17M\x1b[<2;81;17m") drain(0.8) osc52 = re.findall(rb"\x1b\]52;c;([A-Za-z0-9+/=]+)", output) assert osc52, "no OSC 52 clipboard write after menu copy" @@ -871,7 +871,7 @@ def assert_snapshot_parser_controls(): for s in w["screens"] for p in s["panes"] ) -os.write(fd, b"\x1b[<2;81;6M\x1b[<34;81;9M\x1b[<2;81;9m") +os.write(fd, b"\x1b[<2;81;6M\x1b[<34;81;10M\x1b[<2;81;10m") drain(1.0) tabs_after = sum( len(p["tabs"]) diff --git a/cmux-tui/spec/README.md b/cmux-tui/spec/README.md index d63922a83430..0b397fe2590a 100644 --- a/cmux-tui/spec/README.md +++ b/cmux-tui/spec/README.md @@ -24,7 +24,7 @@ Protocol v7 is additive for v6 clients: `attach-surface.mode` defaults to `"byte Generated clients must inspect `identify.protocol` before using features newer than the connected server. Bindings may expose proposed APIs behind version checks, but they must not send proposed commands to an older server unless the caller explicitly opts into probing. -`identify.capabilities` negotiates additive build-level features within one protocol version. Clients must treat a missing capability list as empty. They must require `attach-initial-size` before sending initial `cols` or `rows` on `attach-surface`, `surface-subscribe-filter` before sending `surface` on `subscribe`, `workspace-registry-v1` before using registry creation, placement, stable-key, or revision-CAS APIs, `clear-history-v1` before sending `clear-history`, both `clear-history-v1` and `clear-history-key-v1` before including its structured `fallback_key`, and `provider-managed-workspace-authority-v2` before committing provider-owned workspace mirrors with a pre-provisioned authority. +`identify.capabilities` negotiates additive build-level features within one protocol version. Clients must treat a missing capability list as empty. They must require `attach-initial-size` before sending initial `cols` or `rows` on `attach-surface`, `surface-subscribe-filter` before sending `surface` on `subscribe`, `workspace-registry-v1` before using registry creation, placement, stable-key, or revision-CAS APIs, `viewport-splits-v1` before creating horizontal viewport columns, `viewport-column-resize-v1` before resizing those columns, `layout-undo-v1` before sending `undo-layout`, `clear-history-v1` before sending `clear-history`, both `clear-history-v1` and `clear-history-key-v1` before including its structured `fallback_key`, and `provider-managed-workspace-authority-v2` before committing provider-owned workspace mirrors with a pre-provisioned authority. ## Generation Model diff --git a/cmux-tui/spec/bindings.md b/cmux-tui/spec/bindings.md index 40a4b8404039..f03febd1fda8 100644 --- a/cmux-tui/spec/bindings.md +++ b/cmux-tui/spec/bindings.md @@ -12,7 +12,7 @@ Bindings must: | Requirement | Contract | | --- | --- | -| Version check | Call `identify` or require the caller to supply protocol compatibility before using newer features; require `attach-initial-size` for initial attach sizing, `workspace-registry-v1` for registry APIs, `clear-history-v1` for `clear-history`, and `clear-history-key-v1` for its structured fallback key | +| Version check | Call `identify` or require the caller to supply protocol compatibility before using newer features; require `attach-initial-size` for initial attach sizing, `workspace-registry-v1` for registry APIs, `viewport-splits-v1` for `new-pane-right`, `viewport-column-resize-v1` for `set-viewport-pane-width`, `layout-undo-v1` for structural undo, `clear-history-v1` for `clear-history`, and `clear-history-key-v1` for its structured fallback key | | Error handling | Preserve the server error string and expose a typed transport vs command distinction | | Events | Route response lines and event lines correctly on full-duplex connections | | Attach | Preserve attach ordering for the selected mode: v5 `vt-state`, then `output`, then `detached`; v6 byte mode `vt-state`, then `(resized | output | colors-changed | scroll-changed)*`, then `detached`; v7 render mode `render-state`, then `(render-delta | scroll-changed)*`, then `detached` | @@ -31,7 +31,7 @@ SDKs must treat `layout.split` and `set-split-ratio` as protocol-v8 features. A ## Protocol v9 SDK Expectations -SDKs must treat stack layout nodes and `new-pane` as protocol-v9 features. `new-pane` must fail locally before sending when the identified server reports protocol 8 or older. +SDKs must treat stack layout nodes and `new-pane` as protocol-v9 features. `new-pane` must fail locally before sending when the identified server reports protocol 8 or older. `undo-layout` is separately gated by `layout-undo-v1`; bindings must expose the confirmation result and require callers to pass its exact revision before setting `confirm_close`. A binding must reject an `undo-layout` response unless it contains exactly one complete result variant. It must not coerce missing ids to zero, missing `closes_panes` to an empty array, or invalid pane ids into valid values. SDKs must expose `clear-history` as a typed method and fail locally before sending when the identified server omits `clear-history-v1`. They must expose `TerminalKeyInput` and `TerminalModifiers` as typed values, preserve every `fallback_key` field, and fail locally before sending a fallback when the server omits `clear-history-key-v1` or its `utf8` field exceeds 4 KiB of UTF-8. diff --git a/cmux-tui/spec/cli.md b/cmux-tui/spec/cli.md index 1da46266bbd3..870011af32ca 100644 --- a/cmux-tui/spec/cli.md +++ b/cmux-tui/spec/cli.md @@ -110,12 +110,15 @@ The generated CLI requires one of `--index` or `--delta` for `select-tab`, `sele | `new-workspace` | implemented | none | `--name `, `--cols --rows ` | surface id | | `new-screen` | implemented | none | `--workspace `, `--cols --rows ` | surface id | | `new-pane` | implemented | `--pane ` | `--cols --rows ` | surface id | -| `split` | implemented | `--pane --dir right|down` | `--cols --rows ` | surface id | -| `set-ratio` | implemented | `--pane --dir right|down --ratio ` | none | none | +| `new-pane-right` | implemented; `viewport-splits-v1` | `--pane ` | `--width `, `--cols --rows ` | surface id | +| `split` | implemented | `--pane --dir right\|down` | `--cols --rows ` | surface id | +| `set-ratio` | implemented | `--pane --dir right\|down --ratio ` | none | none | | `set-split-ratio` | implemented | `--split --ratio ` | none | none | -| `pane-neighbor` | implemented | `--pane --dir left|right|up|down` | none | pane id or `null` | -| `focus-direction` | implemented | `--dir left|right|up|down` | `--pane ` | pane id | -| `swap-pane` | implemented | `--pane ` plus one of `--dir left|right|up|down`, `--target ` | none | none | +| `set-viewport-pane-width` | implemented; `viewport-column-resize-v1` | `--pane --width ` | none | none | +| `undo-layout` | implemented; `layout-undo-v1` | `--pane ` | `--revision --confirm-close` | undo or confirmation line | +| `pane-neighbor` | implemented | `--pane --dir left\|right\|up\|down` | none | pane id or `null` | +| `focus-direction` | implemented | `--dir left\|right\|up\|down` | `--pane ` | pane id | +| `swap-pane` | implemented | `--pane ` plus one of `--dir left\|right\|up\|down`, `--target ` | none | none | | `zoom-pane` | implemented | none | `--pane `, `--mode toggle|on|off` | zoom state line | | `process-info` | implemented | `--surface ` | none | process metadata line | | `set-default-colors` | implemented | none | `--fg #rrggbb`, `--bg #rrggbb` | none | diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index d1805b7a459c..763268d0fb7d 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -63,10 +63,16 @@ object{ active_pane:Id, zoomed_pane:Id|null, layout:Layout, + viewport_base_width?:float32, + viewport_splits?:array, panes:array } ``` +Servers advertising `viewport-splits-v1` include `viewport_splits` when a screen uses horizontal viewport columns. Each entry marks a right split whose second child is appended to a horizontal virtual canvas. `width` is the second child's width as a fraction of each frontend's viewport. Ordinary screens omit the field. Clients that do not implement the capability may ignore it and render the split's fallback ratio. + +Servers advertising `viewport-column-resize-v1` include `viewport_base_width` when horizontal viewport layout is active. It is the width of the first column as a fraction of the frontend viewport. A missing value defaults to `1.0`. + `Layout`: ```text @@ -182,7 +188,7 @@ object{app:"cmux-tui",version:string,build_commit?:string|null,ghostty_commit?:s `build_commit` and `ghostty_commit` are additive build-stamp fields. They are omitted or `null` when the binary was built without the corresponding stamp, so clients must preserve compatibility with older servers and unstamped local builds. -`capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. +`capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. Errors: @@ -204,7 +210,7 @@ Example: ```json {"id":1,"cmd":"identify"} -{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","build_commit":"abc123","ghostty_commit":"def456","protocol":10,"capabilities":["attach-initial-size","surface-subscribe-filter","workspace-registry-v1","clear-history-v1","clear-history-key-v1","provider-managed-workspace-authority-v2"],"session":"main","pid":12345}} +{"id":1,"ok":true,"data":{"app":"cmux-tui","version":"0.1.0","build_commit":"abc123","ghostty_commit":"def456","protocol":10,"capabilities":["attach-initial-size","workspace-registry-v1","viewport-splits-v1","viewport-column-resize-v1","layout-undo-v1","clear-history-v1","surface-subscribe-filter","provider-managed-workspace-authority-v2","clear-history-key-v1"],"session":"main","pid":12345}} ``` The current server reports protocol `10` in this field and in `ping`. Clients must negotiate protocol 8 before requiring stable split ids or sending `set-split-ratio`, protocol 9 before decoding stack layouts or sending `new-pane`, and protocol 10 before using per-surface client sizing. @@ -598,13 +604,15 @@ Params: Result: ```text -object{layout:Layout,panes:array}>} +object{layout:Layout,viewport_base_width?:float32,viewport_splits?:array,panes:array}>} ``` Errors: `unknown screen `, `no active screen`, `bad request: ...`. CLI mapping: verb `export-layout`; flags `[--screen ]`; plain stdout and JSON stdout both print the exact result object. +The result is an identity-bearing runtime snapshot for inspection. Its `layout` is not the declarative `layout` input accepted by `apply-layout`. + ### apply-layout | Field | Value | @@ -1216,13 +1224,13 @@ Example: | status | implemented | | since | protocol 9 | -Creates a PTY pane after the current panes in creation order, focuses it, and reapplies the default automatic layout. Panes one through five use one full-height left column and up to four equal right-side rows. Panes six through twelve fill balanced columns of four. Above twelve panes, the first pane stays full-height on the left while the remaining panes form a right-side stack whose focused member expands. The new surface inherits the active surface working directory of `pane` when available. +Creates a PTY pane after the current panes in creation order, focuses it, and reapplies the default automatic layout inside the horizontal viewport column containing `pane`. A screen without horizontal viewport columns is one implicit column, preserving the original whole-screen behavior. Panes one through five use one full-height left column and up to four equal right-side rows. Panes six through twelve fill balanced columns of four. Above twelve panes, the first pane stays full-height on the left while the remaining panes form a right-side stack whose focused member expands. The new surface inherits the active surface working directory of `pane` when available. Params: | Name | JSON type | Required/default | Constraints | | --- | --- | --- | --- | -| `pane` | `Id` | required | Pane whose screen receives the new pane | +| `pane` | `Id` | required | Pane whose horizontal column receives the new pane | | `cols` | `uint16` | default null | Paired with `rows`; final value clamped to at least 1 | | `rows` | `uint16` | default null | Paired with `cols`; final value clamped to at least 1 | @@ -1257,6 +1265,169 @@ Example: {"id":10,"ok":true,"data":{"surface":14}} ``` +### new-pane-right + +| Field | Value | +| --- | --- | +| name | `new-pane-right` | +| status | implemented | +| since | protocol 9 additive capability `viewport-splits-v1` | + +Creates and focuses one PTY column immediately to the right of the horizontal viewport column containing `pane`. Supporting frontends keep each existing column at its independent viewport-relative width and insert the new pane at `width` times the viewport width. The default is two thirds. The shared split tree stores equivalent proportional fallback ratios for clients that ignore viewport metadata. The new surface inherits the active surface working directory of `pane` when available. + +Params: + +| Name | JSON type | Required/default | Constraints | +| --- | --- | --- | --- | +| `pane` | `Id` | required | Pane whose screen receives the new pane | +| `width` | `float32` | default `0.6666667` | From 0.1 through 1.0 | +| `cols` | `uint16` | default null | Paired with `rows`; final value clamped to at least 1 | +| `rows` | `uint16` | default null | Paired with `cols`; final value clamped to at least 1 | + +Result: + +```text +object{surface:Id} +``` + +Errors: + +| Error | Condition | +| --- | --- | +| `pane has no workspace` | Target pane is not in a screen | +| `viewport pane width must be between 0.1 and 1.0` | `width` is outside the supported range | +| `pane creation failed` | PTY creation or child spawn fails; raw runtime details are logged internally only | +| `bad request: ...` | Missing fields or wrong JSON type | + +CLI mapping: + +| Item | Value | +| --- | --- | +| Verb | `new-pane-right` | +| Flags | `--pane [--width ] [--cols --rows ]` | +| Plain stdout | new surface id followed by newline | +| JSON stdout | exact result object | +| Exit codes | common | + +Example: + +```json +{"id":11,"cmd":"new-pane-right","pane":2} +{"id":11,"ok":true,"data":{"surface":15}} +``` + +### set-viewport-pane-width + +| Field | Value | +| --- | --- | +| name | `set-viewport-pane-width` | +| status | implemented | +| since | protocol 9 additive capability `viewport-column-resize-v1` | + +Sets the width of the horizontal viewport column containing `pane`. Every pane nested inside that column keeps its internal split ratios. The command updates proportional fallback ratios for clients that ignore viewport metadata. + +Params: + +| Name | JSON type | Required/default | Constraints | +| --- | --- | --- | --- | +| `pane` | `Id` | required | Must belong to a screen with viewport columns | +| `width` | `float32` | required | Finite value from 0.1 through 1.0 | +| `transaction` | `uint64` | default null | Samples with the same connection and transaction coalesce into one undo entry | + +Result: empty object. + +Errors: + +| Error | Condition | +| --- | --- | +| `viewport pane width must be between 0.1 and 1.0` | `width` is non-finite or outside the supported range | +| `pane has no resizable viewport column` | Pane is unknown or its screen has no viewport columns | +| `bad request: ...` | Missing fields or wrong JSON type | + +Invalid widths return `error_code:"viewport-width-out-of-range"`. A missing pane or a pane outside a viewport layout returns `error_code:"viewport-column-not-found"`. + +CLI mapping: + +| Item | Value | +| --- | --- | +| Verb | `set-viewport-pane-width` | +| Flags | `--pane --width ` | +| Plain stdout | empty | +| JSON stdout | exact result object | +| Exit codes | common | + +Example: + +```json +{"id":12,"cmd":"set-viewport-pane-width","pane":15,"width":0.5} +{"id":12,"ok":true,"data":{}} +``` + +### undo-layout + +| Field | Value | +| --- | --- | +| name | `undo-layout` | +| status | implemented | +| since | protocol 9 additive capability `layout-undo-v1` | + +Undoes the latest structural layout entry on the screen containing `pane`. History is owned by that screen, capped at 32 entries, and kept in memory only. Resize samples carrying the same connection-scoped `transaction` coalesce. A new transaction, another connection, or a request without a transaction starts a new undo entry. Pane creation, split and column resize, swap, zoom, and automatic-layout changes are undoable. A direct pane close clears that screen's history because the closed process cannot be recreated. + +If the entry created panes, the first request returns a confirmation preview. The server advances to a unique confirmation revision and binds it to the exact ordered surface membership of every pane in `closes_panes`. The client must show the consequence, then resend that revision with `confirm_close:true`. A later structural change, tab addition, tab removal, tab reorder, tab move, or newer preview invalidates the confirmation. A rejected or stale confirmation closes nothing. + +Clients must reject the response unless it contains exactly one complete result variant. The applied variant requires `undone:true`, `screen`, and `revision`, with `confirmation_required` either absent or false. The preview variant requires `undone:false`, `confirmation_required:true`, `screen`, `revision`, and an array of valid pane ids in `closes_panes`. Missing fields, contradictory outcome flags, invalid ids, and non-array `closes_panes` values are protocol errors. + +Params: + +| Name | JSON type | Required/default | Constraints | +| --- | --- | --- | --- | +| `pane` | `Id` | required | Selects the screen whose history is used | +| `revision` | `uint64` | default null | Required for a confirmed pane-closing undo; must equal the preview revision | +| `confirm_close` | `boolean` | default false | Must be true to commit an undo that closes panes | + +Result: + +```text +object{undone:true,screen:Id,revision:uint64} +| object{undone:false,confirmation_required:true,screen:Id,revision:uint64,closes_panes:array} +``` + +Errors: + +| Error | Condition | +| --- | --- | +| `unknown pane ` | `pane` is not in a live screen | +| `no layout change to undo` | The selected screen has no undo entry | +| `confirmed layout undo requires the preview revision` | `confirm_close` is true without `revision` | +| `layout revision conflict: expected , current ` | The confirmation revision is stale or incorrect | +| `tabs in pane changed since the undo confirmation` | A pane's surface membership differs from the preview | +| `layout changed before undo could commit` | The layout changed after validation and before commit | +| `bad request: ...` | Missing fields or wrong JSON type | + +Expected failures also include a machine-readable response `error_code`. +`layout-undo-unavailable` means the screen has no undo entry. +`layout-undo-stale` means a previously valid entry or confirmation can no +longer commit. Other failures omit `error_code`. + +CLI mapping: + +| Item | Value | +| --- | --- | +| Verb | `undo-layout` | +| Flags | `--pane [--revision --confirm-close]` | +| Plain stdout | undo line, or confirmation instructions with the revision and closing pane ids | +| JSON stdout | exact result object | +| Exit codes | common | + +Examples: + +```json +{"id":13,"cmd":"undo-layout","pane":15} +{"id":13,"ok":true,"data":{"undone":false,"confirmation_required":true,"screen":3,"revision":8,"closes_panes":[15]}} +{"id":14,"cmd":"undo-layout","pane":15,"revision":8,"confirm_close":true} +{"id":14,"ok":true,"data":{"undone":true,"screen":3,"revision":9}} +``` + ### split | Field | Value | @@ -1338,6 +1509,7 @@ Errors: | --- | --- | | `bad dir "" (want "right" or "down")` | `dir` is not allowed | | `unknown pane/split ` | Pane is unknown or no ancestor split has `dir` | +| `split ratio ... width must be between 0.1 and 1` | The projected viewport split cannot represent the clamped ratio | | `bad request: ...` | Missing fields or wrong JSON type | CLI mapping: @@ -1367,7 +1539,7 @@ Example: | status | implemented | | since | protocol 8 | -Sets the ratio of exactly one canonical split node. The server clamps the supplied ratio to `0.05..0.95`. The split id and every unrelated node remain unchanged. +Sets the ratio of exactly one canonical split node. The server clamps the supplied ratio to `0.05..0.95`. The split id and every unrelated node remain unchanged. A compatibility split representing a horizontal viewport column also preserves the column width invariant `0.1..1.0`. Params: @@ -1375,6 +1547,7 @@ Params: | --- | --- | --- | --- | | `split` | `Id` | required | Stable split id from `list-workspaces` or `export-layout` | | `ratio` | `float32` | required | Clamped to `0.05..0.95` | +| `transaction` | `uint64` | default null | Samples with the same connection and transaction coalesce into one undo entry | Result: `object{}`. @@ -1383,8 +1556,11 @@ Errors: | Error | Condition | | --- | --- | | `unknown split ` | No live split node has the id | +| `split ratio ... width must be between 0.1 and 1` | The live viewport split would require an unsupported column width; layout remains unchanged | | `bad request: ...` | Missing fields or wrong JSON type | +Missing targets return `error_code:"layout-ratio-target-missing"`. Unsupported viewport widths return `error_code:"layout-ratio-out-of-range"`. + CLI mapping: | Item | Value | @@ -3170,3 +3346,9 @@ The following v5 behaviors are awkward for generated bindings and should be norm | Unknown fields | Unknown request fields are ignored by serde | Reject unknown fields or define extension slots | Protocol v9 adds `new-pane`; its implemented result is `{surface}`. A future result expansion may add `{pane,screen,workspace}` only behind a newer protocol version. + +`viewport-splits-v1` is additive within protocol v9. Clients must require the capability before sending `new-pane-right` or interpreting `Screen.viewport_splits`. + +`viewport-column-resize-v1` is additive within protocol v9. Clients must require the capability before sending `set-viewport-pane-width` or interpreting `Screen.viewport_base_width`. + +`layout-undo-v1` is additive within protocol v9. Clients must require the capability before sending `undo-layout`. A binding must preserve both result variants and must not set `confirm_close` without the exact revision returned by the confirmation preview. diff --git a/cmux-tui/spec/transports.md b/cmux-tui/spec/transports.md index 5866a4ce3daa..2645a50c59c9 100644 --- a/cmux-tui/spec/transports.md +++ b/cmux-tui/spec/transports.md @@ -4,7 +4,7 @@ The command schema is transport-independent. Protocol v5 introduced the Unix dom ## Protocol Negotiation -The current server reports `protocol:10` from `identify` and `ping`. Clients must inspect `identify.protocol` before using versioned additions. A client selecting `attach-surface` with `mode:"render"` must require `protocol >= 7`; on protocol 6 it must use the default byte mode or refuse the attachment. A client requiring stable split ids or sending `set-split-ratio` must require protocol 8. A client decoding stack layouts or sending `new-pane` must require protocol 9. A client using `set-client-sizing` must require protocol 10 and include its target surface. +The current server reports `protocol:10` from `identify` and `ping`. Clients must inspect `identify.protocol` before using versioned additions. A client selecting `attach-surface` with `mode:"render"` must require `protocol >= 7`; on protocol 6 it must use the default byte mode or refuse the attachment. A client requiring stable split ids or sending `set-split-ratio` must require protocol 8. A client decoding stack layouts or sending `new-pane` must require protocol 9. A client using `set-client-sizing` must require protocol 10 and include its target surface. A client sending `new-pane-right` or interpreting `Screen.viewport_splits` must require the additive `viewport-splits-v1` capability. A client sending `set-viewport-pane-width` or interpreting `Screen.viewport_base_width` must require `viewport-column-resize-v1`. A client sending `undo-layout` must require `layout-undo-v1`. There is no transport-level version preamble. Omitting `attach-surface.mode` selects `"bytes"`, and omitting `subscribe.tree_events` selects `"coarse"`; those defaults preserve the exact protocol-v6 attach and tree-event behavior. Unix socket paths, WebSocket upgrade/authentication, request ids, response envelopes, and message framing do not change in protocol 7. @@ -51,9 +51,13 @@ Response envelope: ```text object{id?:any,ok:true,data:any} -| object{id?:any,ok:false,error:string,error_delivery?:"known-not-delivered"|"ambiguous"} +| object{id?:any,ok:false,error:string,error_code?:string,error_delivery?:"known-not-delivered"|"ambiguous"} ``` +`error_code` is an additive machine-readable classification for commands that +define one. Clients must continue to display or log `error` and ignore unknown +codes. + Decode errors return: ```text