Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 73 additions & 3 deletions cmux-linux/src/socket.zig
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ const methods = .{
.{ "pane.surfaces", handlePaneSurfaces },
.{ "pane.last", handlePaneLast },
.{ "pane.swap", handlePaneSwap },
.{ "pane.resize", handlePaneResize },
.{ "pane.break", handlePaneBreak },
.{ "pane.join", handlePaneJoin },
.{ "workspace.move_to_window", handleWorkspaceMoveToWindow },
Expand Down Expand Up @@ -1775,8 +1776,8 @@ fn handleSurfaceAction(alloc: Allocator, params: json.Value) []const u8 {
const idx = anchor_idx orelse return "{\"error\":\"surface not in panel order\"}";

// Collect IDs to remove (snapshot before mutation).
var to_close = std.ArrayList(u128).init(alloc);
defer to_close.deinit();
var to_close = std.ArrayList(u128).empty;
defer to_close.deinit(alloc);
var skipped_pinned: usize = 0;

for (ws.ordered_panels.items, 0..) |id, i| {
Expand All @@ -1797,7 +1798,7 @@ fn handleSurfaceAction(alloc: Allocator, params: json.Value) []const u8 {
continue;
}
}
to_close.append(id) catch continue;
to_close.append(alloc, id) catch continue;
}

// Remove collected panels (split tree + panel map).
Expand Down Expand Up @@ -1966,6 +1967,75 @@ fn handlePaneLast(alloc: Allocator, _: json.Value) []const u8 {
return std.fmt.allocPrint(alloc, "{{\"pane_id\":\"{s}\"}}", .{@as([]const u8, &panel_hex)}) catch "{}";
}

fn handlePaneResize(alloc: Allocator, params: json.Value) []const u8 {
const tm = getTabManager() orelse return "{\"error\":\"no tab manager\"}";

const ws = if (getParamString(params, "workspace_id")) |id_str|
if (findWorkspaceById(tm, id_str)) |found| found.ws else return "{\"error\":\"invalid workspace_id\"}"
else
tm.selectedWorkspace() orelse return "{\"error\":\"no workspace\"}";

const dir_str = getParamString(params, "direction") orelse
return "{\"error\":\"missing direction (left|right|up|down)\"}";

const amount_raw = getParamInt(params, "amount") orelse 1;
if (amount_raw < 1) return "{\"error\":\"amount must be >= 1\"}";
const amount: usize = @intCast(amount_raw);

// Map direction to split orientation + which child the pane must be in.
const orientation: split_tree.Orientation = if (std.mem.eql(u8, dir_str, "left") or std.mem.eql(u8, dir_str, "right"))
.horizontal
else if (std.mem.eql(u8, dir_str, "up") or std.mem.eql(u8, dir_str, "down"))
.vertical
else
return "{\"error\":\"direction must be left|right|up|down\"}";

// "right"/"down" → grow first child → pane in first child, increase ratio
// "left"/"up" → grow second child → pane in second child, decrease ratio
const in_first = std.mem.eql(u8, dir_str, "right") or std.mem.eql(u8, dir_str, "down");

const target_id = if (getParamString(params, "pane_id")) |id_str|
findSurfaceInWorkspace(ws, id_str) orelse return "{\"error\":\"invalid pane_id\"}"
else
ws.focused_panel_id orelse return "{\"error\":\"no focused pane\"}";

const root = ws.root_node orelse return "{\"error\":\"no split tree\"}";

const split = split_tree.findResizeSplit(root, target_id, orientation, in_first) orelse
return std.fmt.allocPrint(
alloc,
"{{\"error\":\"no {s} split ancestor in direction {s}\"}}",
.{ @tagName(orientation), dir_str },
) catch "{\"error\":\"no matching split\"}";

// Each unit of `amount` adjusts the ratio by 0.05 (5%).
const delta: f64 = @as(f64, @floatFromInt(amount)) * 0.05;
const sign: f64 = if (in_first) 1.0 else -1.0;
const requested = split.ratio + (sign * delta);
split.ratio = @min(@max(requested, 0.1), 0.9);

// Update the GtkPaned position in real mode.
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);
}
}
Comment on lines +2018 to +2028

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", .{});
}

}

const pane_hex = formatId(target_id);
return std.fmt.allocPrint(
alloc,
"{{\"pane_id\":\"{s}\",\"direction\":\"{s}\",\"ratio\":{d:.4}}}",
.{ @as([]const u8, &pane_hex), dir_str, split.ratio },
) catch "{}";
}

// ── Batch 4: Complex Structural Operations ──────────────────────────────

fn handlePaneSwap(_: Allocator, params: json.Value) []const u8 {
Expand Down
42 changes: 42 additions & 0 deletions cmux-linux/src/split_tree.zig
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,48 @@ pub fn findLeaf(node: *Node, panel_id: u128) ?*Leaf {
};
}

/// Check whether a subtree contains a given panel.
pub fn containsPanel(node: *const Node, panel_id: u128) bool {
return switch (node.*) {
.leaf => |leaf| leaf.panel_id == panel_id,
.split => |split| containsPanel(split.first, panel_id) or containsPanel(split.second, panel_id),
};
}

/// Find the innermost split ancestor of `panel_id` whose orientation
/// matches `target_orientation` and where the panel is in the first
/// child (if `in_first` is true) or the second child (if false).
///
/// Returns a mutable pointer to the matching Split, or null if none
/// found. The caller can then adjust `split.ratio` to resize.
pub fn findResizeSplit(
node: *Node,
panel_id: u128,
target_orientation: Orientation,
in_first: bool,
) ?*Split {
switch (node.*) {
.leaf => return null,
.split => |*split| {
const in_first_child = containsPanel(split.first, panel_id);
if (!in_first_child and !containsPanel(split.second, panel_id))
return null;

// Recurse into the child that contains the panel first
// so innermost matches win.
const child = if (in_first_child) split.first else split.second;
if (findResizeSplit(child, panel_id, target_orientation, in_first)) |inner|
return inner;

// If this split matches, return it.
if (split.orientation == target_orientation and in_first_child == in_first)
return split;

return null;
},
}
}

/// Recursively destroy all nodes.
pub fn destroy(alloc: Allocator, node: *Node) void {
switch (node.*) {
Expand Down
163 changes: 163 additions & 0 deletions tests_v2/test_pane_resize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Socket API: pane.resize adjusts split divider ratios.

Verifies:
1. Resize right grows the first child's share (ratio increases)
2. Resize left grows the second child's share (ratio decreases)
3. Ratio is clamped to [0.1, 0.9]
4. Resize with custom amount steps the ratio proportionally
5. Error on no split (single-pane workspace)
6. Error on bad direction
7. Error on no matching split ancestor for the given direction
"""

import os
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError

SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")


def _expect_error(label: str, fn) -> None:
try:
fn()
except cmuxError:
return
raise cmuxError(f"{label}: expected error, got success")


def main() -> int:
ws = None
with cmux(SOCKET_PATH) as c:
try:
ws = c.new_workspace()
c.select_workspace(ws)
time.sleep(0.1)

# Single pane → resize should error (no split tree or trivial root)
surfaces_1 = c.list_surfaces()
if not surfaces_1:
raise cmuxError("no surfaces in fresh workspace")
solo_id = surfaces_1[0][1]

_expect_error(
"resize single pane",
lambda: c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": solo_id, "direction": "right"},
),
)

# Split right → creates [s0 | s1] horizontal split
s1 = c.new_split("right")
time.sleep(0.1)
if not s1:
raise cmuxError("surface.split returned no id")

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]
Comment on lines +61 to +65

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")


# Resize right from s0 (first child) → ratio increases from 0.5
res1 = c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": s0_id, "direction": "right"},
)
if not isinstance(res1, dict):
raise cmuxError(f"resize right returned {res1!r}")
r1 = res1.get("ratio")
if r1 is None or r1 <= 0.5:
raise cmuxError(f"resize right should increase ratio above 0.5, got {r1}")

# Resize left from s1 (second child) → ratio decreases
res2 = c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": s1_id, "direction": "left"},
)
r2 = res2.get("ratio")
if r2 is None or r2 >= r1:
raise cmuxError(f"resize left should decrease ratio below {r1}, got {r2}")

# Multiple amount: resize right by 5 from s0 → big jump
res3 = c._call(
"pane.resize",
{
"workspace_id": ws,
"pane_id": s0_id,
"direction": "right",
"amount": 5,
},
)
r3 = res3.get("ratio")
if r3 is None or r3 <= r2:
raise cmuxError(f"resize right amount=5 should increase, got {r3}")

# Clamp at 0.9: keep pushing right
for _ in range(20):
c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": s0_id, "direction": "right", "amount": 5},
)
res_max = c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": s0_id, "direction": "right"},
)
r_max = res_max.get("ratio", 0)
if r_max > 0.9 + 0.001:
raise cmuxError(f"ratio should clamp at 0.9, got {r_max}")

# Clamp at 0.1: push left from s1 many times
for _ in range(20):
c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": s1_id, "direction": "left", "amount": 5},
)
res_min = c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": s1_id, "direction": "left"},
)
r_min = res_min.get("ratio", 1)
if r_min < 0.1 - 0.001:
raise cmuxError(f"ratio should clamp at 0.1, got {r_min}")

# Bad direction → error
_expect_error(
"bad direction",
lambda: c._call(
"pane.resize",
{"workspace_id": ws, "pane_id": s0_id, "direction": "diagonal"},
),
)

# 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"},
),
)
Comment on lines +138 to +145

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.


finally:
if ws is not None:
try:
c.close_workspace(ws)
except Exception:
pass

print("PASS: pane.resize (ratio adjust, clamp, errors)")
return 0


if __name__ == "__main__":
try:
raise SystemExit(main())
except cmuxError as exc:
print(f"FAIL: {exc}", file=sys.stderr)
raise SystemExit(1)
Loading