Skip to content

linux(socket): implement pane.resize via split tree (Sprint A #3) - #228

Merged
Jesssullivan merged 2 commits into
mainfrom
sid/socket-pane-resize
Apr 18, 2026
Merged

Jesssullivan merged 2 commits into
mainfrom
sid/socket-pane-resize

Conversation

@Jesssullivan

Copy link
Copy Markdown
Owner

Summary

Implements the v2 `pane.resize` RPC on Linux by walking the split tree
to find the innermost ancestor split node matching the requested direction
and adjusting its divider ratio.

How it works

  • `direction`: left/right → horizontal splits, up/down → vertical splits
  • `amount` (default 1): each unit adjusts ratio by 0.05 (5%)
  • Ratio clamped to [0.1, 0.9]
  • In real GTK mode, updates the GtkPaned widget position immediately
  • Returns `{pane_id, direction, ratio}` with the new ratio

What's added

  • `cmux-linux/src/split_tree.zig`:
    • `containsPanel`: check if a subtree contains a given panel
    • `findResizeSplit`: find innermost matching split ancestor for a pane,
      given orientation and first/second-child position
  • `cmux-linux/src/socket.zig`: `handlePaneResize` handler (~65 LOC)
  • `tests_v2/test_pane_resize.py` covering:
    • ratio increase (right) and decrease (left)
    • multi-step amount
    • clamping at 0.1/0.9 bounds
    • single-pane error (no split tree)
    • bad direction error
    • no-matching-split-ancestor error (e.g. up/down on horizontal-only split)

Linux vs macOS

macOS computes delta from pixel dimensions
(`amount / axisPixels`). Linux uses a fixed `0.05` step per unit since
CMUX_NO_SURFACE mode has no real pixel allocations. In real GTK mode
the ratio is applied to the GtkPaned's actual allocation.

Test plan

  • Socket tests CI is green
  • Distro tests CI is green

Refs #220 (Sprint A item #3).

Adds pane.resize to the v2 socket protocol on Linux. Walks the split
tree to find the innermost ancestor split node matching the requested
direction (left/right → horizontal, up/down → vertical) and adjusts
its ratio by amount * 0.05 per step, clamped to [0.1, 0.9].

New split_tree.zig helpers:
  - containsPanel: check if a subtree contains a given panel id
  - findResizeSplit: find the innermost matching split ancestor for a
    given pane id, orientation, and first/second-child position

The handler resolves the target pane from pane_id (or focused), maps
direction to orientation + child position, finds the split, adjusts
its ratio, and in real GTK mode updates the GtkPaned widget position.
Returns {pane_id, direction, ratio} with the new ratio.

Adds tests_v2/test_pane_resize.py covering: ratio increase/decrease,
multi-step amount, clamping at 0.1/0.9 bounds, single-pane error,
bad direction error, and no-matching-split-ancestor error.

Refs #220 (Sprint A item #3).
@greptile-apps

greptile-apps Bot commented Apr 18, 2026 •

Copy link
Copy Markdown

Greptile Summary

Implements pane.resize for the Linux socket API by walking the split tree to find the innermost ancestor split matching the requested orientation and child position, then adjusting its divider ratio (clamped to [0.1, 0.9]). The follow-up fix commit corrects ArrayList(u128) initialization in the close_variants handler to use the .empty + alloc pattern consistent with the codebase.

The findResizeSplit implementation is logically correct: "innermost wins" recursion, proper containsPanel guard, and safe @intCast after the amount < 1 check. All P2 findings below.

Confidence Score: 5/5

Safe to merge — no P0/P1 findings; implementation is logically correct and the fix commit resolves a real compile error.

All three findings are P2 (error message clarity, missing isinstance guards in tests, uncovered focused-pane path). The core findResizeSplit logic, ratio clamping, GTK update path, and the ArrayList fix are all correct. Previous P1-level concerns from the thread (null paned silent skip) are pre-existing and acknowledged.

tests_v2/test_pane_resize.py — minor robustness and coverage gaps, but nothing blocking.

Important Files Changed

Filename Overview
cmux-linux/src/split_tree.zig Adds containsPanel (recursive subtree search) and findResizeSplit (innermost-ancestor search by orientation + child position). Logic is correct: early null on leaf, one containsPanel call per side, innermost-wins recursion, and safe *const Node coercion from *Node in containsPanel.
cmux-linux/src/socket.zig Adds handlePaneResize (~65 LOC): workspace + pane resolution, direction → orientation+in_first mapping, ratio clamping, optional GTK position update. Fix commit also corrects ArrayList(u128) initialization in close_variants from deprecated .init(alloc) to .empty + deinit(alloc) pattern. Both changes are correct.
tests_v2/test_pane_resize.py Covers ratio increase/decrease, multi-step amount, 0.1/0.9 clamp, single-pane error, bad direction error, and no-ancestor error. Missing: positive vertical-split case (noted in prior thread), pane_id-omitted fallback, and isinstance checks on res2/res3 that would surface unexpected non-dict returns as unhelpful AttributeError instead of cmuxError.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[pane.resize RPC] --> B{workspace_id?}
    B -- yes --> C[findWorkspaceById]
    B -- no --> D[selectedWorkspace]
    C --> E{pane_id?}
    D --> E
    E -- yes --> F[findSurfaceInWorkspace]
    E -- no --> G[ws.focused_panel_id]
    F --> H{root_node?}
    G --> H
    H -- null --> I[error: no split tree]
    H -- leaf/split --> J[findResizeSplit]
    J --> K{match found?}
    K -- null --> L["error: no {orientation} split ancestor"]
    K -- Split ptr --> M[clamp ratio to 0.1-0.9]
    M --> N{isNoSurface?}
    N -- false --> O{split.paned != null?}
    O -- yes --> P[gtk_paned_set_position]
    O -- no --> Q[skip GTK update]
    N -- true --> R[skip GTK update]
    P --> S["return {pane_id, direction, ratio}"]
    Q --> S
    R --> S
Loading

Reviews (2): Last reviewed commit: "fix(socket): use .empty + alloc for Arra..." | Re-trigger Greptile

Comment on lines +61 to +65
surfaces_2 = c.list_surfaces()
if len(surfaces_2) < 2:
raise cmuxError(f"expected >= 2 surfaces after split, got {len(surfaces_2)}")
s0_id = surfaces_2[0][1]
s1_id = surfaces_2[1][1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Split-tree ordering assumed but not verified

s0_id and s1_id are assigned from list_surfaces() index positions, implicitly assuming the list returns panes in split-tree order (first child before second child). If the surface list is ordered by insertion time or some other criterion, the "resize right from s0" assertion can silently test the wrong pane. A quick guard — comparing s1_id against the s1 surface-id returned by new_split — would make the assumption explicit and catch ordering regressions:

s1 = c.new_split("right")
time.sleep(0.1)
surfaces_2 = c.list_surfaces()
s0_id = surfaces_2[0][1]
s1_id = surfaces_2[1][1]
# Verify list ordering matches split position
if s1_id != s1:
    raise cmuxError(f"expected s1_id={s1!r}, list returned {s1_id!r} as second surface")

Comment on lines +138 to +145
# Resize up/down on a horizontal-only split → no matching ancestor
_expect_error(
"no vertical split",
lambda: c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": s0_id, "direction": "up"},
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No positive test for vertical splits

The test verifies that up/down on a horizontal-only tree returns an error, but never exercises the happy path (ratio increase/decrease on an actual vertical split). A brief addition after the horizontal tests — new_split("down"), then a pane.resize with direction: "down" or "up" — would confirm findResizeSplit returns the right ancestor for .vertical orientation, not just that it fails correctly when none exists.

Comment thread cmux-linux/src/socket.zig
Comment on lines +2018 to +2028
if (!isNoSurface()) {
if (split.paned) |paned_widget| {
const alloc_size = if (orientation == .horizontal)
c.gtk.gtk_widget_get_width(paned_widget)
else
c.gtk.gtk_widget_get_height(paned_widget);
if (alloc_size > 0) {
const pos: c_int = @intFromFloat(split.ratio * @as(f64, @floatFromInt(alloc_size)));
c.gtk.gtk_paned_set_position(@ptrCast(@alignCast(paned_widget)), pos);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent no-op when split.paned is null in real GTK mode

If a split node exists in the model tree but buildWidget has not yet been called for it (e.g. the workspace was created programmatically and not yet laid out), split.paned is null and the GTK position update is silently skipped. The model ratio is still mutated, so subsequent calls will round-trip correctly, but the live GtkPaned widget won't move until some other GTK event causes a relayout. A log.warn on the null branch would surface this in debug builds:

if (split.paned) |paned_widget| {
    // ... existing position update ...
} else {
    log.warn("pane.resize: split.paned is null, GTK position not updated", .{});
}

The close_left/close_right/close_others handler used the old
.init(alloc) and .deinit() API that doesn't exist in this Zig
version. Match the codebase pattern: .empty + .deinit(alloc) +
.append(alloc, id).
@Jesssullivan
Jesssullivan merged commit 4cf90af into main Apr 18, 2026
22 of 27 checks passed
@Jesssullivan
Jesssullivan deleted the sid/socket-pane-resize branch April 18, 2026 05:53

This branch was successfully deployed

No deployments
gpu-tests — 0f538584 Deployed Apr 18, 2026 by Jesssullivan via GPU smoke test (honey) #126
distro-tests — 0f538584 Deployed Apr 18, 2026 by Jesssullivan via Distro package tests (self-hosted KVM) #79
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant