diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7e4d4480..ad779aa2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2906,20 +2906,187 @@ class TerminalController { return result } + private enum V2PaneResizeDirection: String { + case left + case right + case up + case down + + var splitOrientation: String { + switch self { + case .left, .right: + return "horizontal" + case .up, .down: + return "vertical" + } + } + + /// A split controls the target pane's right/bottom edge when target is first child, + /// and left/top edge when target is second child. + var requiresPaneInFirstChild: Bool { + switch self { + case .right, .down: + return true + case .left, .up: + return false + } + } + + /// Positive value moves divider toward second child (right/down). + var dividerDeltaSign: CGFloat { + requiresPaneInFirstChild ? 1 : -1 + } + } + + private struct V2PaneResizeCandidate { + let splitId: UUID + let orientation: String + let paneInFirstChild: Bool + let dividerPosition: CGFloat + let axisPixels: CGFloat + } + + private struct V2PaneResizeTrace { + let containsTarget: Bool + let bounds: CGRect + } + + private func v2PaneResizeCollectCandidates( + node: ExternalTreeNode, + targetPaneId: String, + candidates: inout [V2PaneResizeCandidate] + ) -> V2PaneResizeTrace { + switch node { + case .pane(let pane): + let bounds = CGRect( + x: pane.frame.x, + y: pane.frame.y, + width: pane.frame.width, + height: pane.frame.height + ) + return V2PaneResizeTrace(containsTarget: pane.id == targetPaneId, bounds: bounds) + + case .split(let split): + let first = v2PaneResizeCollectCandidates( + node: split.first, + targetPaneId: targetPaneId, + candidates: &candidates + ) + let second = v2PaneResizeCollectCandidates( + node: split.second, + targetPaneId: targetPaneId, + candidates: &candidates + ) + + let combinedBounds = first.bounds.union(second.bounds) + let containsTarget = first.containsTarget || second.containsTarget + + if containsTarget, + let splitUUID = UUID(uuidString: split.id) { + let orientation = split.orientation.lowercased() + let axisPixels: CGFloat = orientation == "horizontal" + ? combinedBounds.width + : combinedBounds.height + candidates.append(V2PaneResizeCandidate( + splitId: splitUUID, + orientation: orientation, + paneInFirstChild: first.containsTarget, + dividerPosition: CGFloat(split.dividerPosition), + axisPixels: max(axisPixels, 1) + )) + } + + return V2PaneResizeTrace(containsTarget: containsTarget, bounds: combinedBounds) + } + } + private func v2PaneResize(params: [String: Any]) -> V2CallResult { - let direction = (v2String(params, "direction") ?? "").lowercased() + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let directionRaw = (v2String(params, "direction") ?? "").lowercased() let amount = v2Int(params, "amount") ?? 1 - guard ["left", "right", "up", "down"].contains(direction), amount > 0 else { + guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else { return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil) } - return .err( - code: "not_supported", - message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API", - data: [ - "direction": direction, - "amount": amount - ] - ) + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to resize pane", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id + guard let paneUUID else { + result = .err(code: "not_found", message: "No focused pane", data: nil) + return + } + guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else { + result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) + return + } + + let tree = ws.bonsplitController.treeSnapshot() + var candidates: [V2PaneResizeCandidate] = [] + let trace = v2PaneResizeCollectCandidates( + node: tree, + targetPaneId: paneUUID.uuidString, + candidates: &candidates + ) + guard trace.containsTarget else { + result = .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString]) + return + } + + let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation } + guard !orientationMatches.isEmpty else { + result = .err( + code: "invalid_state", + message: "No \(direction.splitOrientation) split ancestor for pane", + data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue] + ) + return + } + + guard let candidate = orientationMatches.first(where: { $0.paneInFirstChild == direction.requiresPaneInFirstChild }) else { + result = .err( + code: "invalid_state", + message: "Pane has no adjacent border in direction \(direction.rawValue)", + data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue] + ) + return + } + + let delta = CGFloat(amount) / candidate.axisPixels + let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta) + let clamped = min(max(requested, 0.1), 0.9) + guard ws.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else { + result = .err( + code: "internal_error", + message: "Failed to set split divider position", + data: ["split_id": candidate.splitId.uuidString] + ) + return + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": paneUUID.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), + "split_id": candidate.splitId.uuidString, + "direction": direction.rawValue, + "amount": amount, + "old_divider_position": candidate.dividerPosition, + "new_divider_position": clamped + ]) + } + return result } private func v2PaneSwap(params: [String: Any]) -> V2CallResult { diff --git a/tests_v2/test_tmux_compat_matrix.py b/tests_v2/test_tmux_compat_matrix.py index 876e4130..59ee3d3a 100644 --- a/tests_v2/test_tmux_compat_matrix.py +++ b/tests_v2/test_tmux_compat_matrix.py @@ -82,6 +82,46 @@ def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> boo return token in str(payload.get("text") or "") +def _layout_panes(c: cmux) -> List[dict]: + layout_payload = c.layout_debug() or {} + layout = layout_payload.get("layout") or {} + panes = layout.get("panes") or [] + return list(panes) + + +def _pane_extent(c: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(c) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _pick_resize_target(c: cmux, pane_ids: List[str]) -> Tuple[str, str, str]: + panes = [p for p in _layout_panes(c) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + target = min(panes, key=x_of) + return str(target.get("paneId") or target.get("pane_id") or ""), "-R", "width" + + target = min(panes, key=y_of) + return str(target.get("paneId") or target.get("pane_id") or ""), "-D", "height" + + def main() -> int: cli = _find_cli_binary() stamp = int(time.time() * 1000) @@ -206,8 +246,13 @@ def main() -> int: merged = f"{proc.stdout}\n{proc.stderr}".lower() _must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}") - resize = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False) - _must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added") + resize_target, resize_flag, resize_axis = _pick_resize_target(c, current_panes) + pre_extent = _pane_extent(c, resize_target, resize_axis) + _run_cli(cli, ["resize-pane", "--pane", resize_target, resize_flag, "--amount", "80"]) + _wait_for( + lambda: _pane_extent(c, resize_target, resize_axis) > pre_extent + 1.0, + timeout_s=3.0, + ) buffer_token = f"TMUX_BUFFER_{stamp}" _run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"])