tiletopia/src/lib/layout/tree.ts
megaproxy bf2810a433 MCP v2 PR-3: write_pane, spawn_pane, connect_host + SSH safeguards
Three of the highest-power v2 tools, plus a defense-in-depth pass
on SSH-specific risk.

write_pane sends keystrokes (or any bytes) to a pane's PTY. The
policy engine matches against the text content directly so rules
like write_pane(npm test*) match by what would run, and the
compiled-in hard-deny catches rm -rf /, fork bombs, etc. regardless
of policy. Per-pane token-bucket rate limiter (30 calls / 10s,
3/sec refill) prevents a runaway loop from spamming the user with
confirm modals or burning audit-log capacity. The frontend handler
truncates the text in modal/audit summaries to ~60 chars + escapes
control characters so secrets pasted into write_pane don't echo
verbatim into the UI.

spawn_pane mirrors the existing SpawnSpec enum (WSL distro,
PowerShell, SSH) as the tool schema. New splitLeafWith helper
inserts a caller-built LeafNode (with a pre-generated id) so the
handler can await waitForPaneRegistration on that exact leaf before
replying with the resulting {leafId, paneId}. 15s spawn timeout
covers cold-start WSL distros; 30s for connect_host covers SSH
handshake + auth. Outer dispatch timeout bumped 30s → 60s. SSH
spawns without a saved hostId are refused — LeafNode only persists
sshHostId, no inline params, so use connect_host.

connect_host is a thin wrapper that looks up a saved SSH host by
id and routes through the same spawn machinery.

McpConfirm.tsx gains an optional ssh context — when the call
targets or spawns an SSH pane, a red warning banner renders
explaining that pattern matching is best-effort on the bytes we
send (remote shell expands aliases/subshells before executing).
buildConfirmSummary became buildConfirmInfo and returns the SSH
context alongside the summary string.

PR-3.5 — SSH safeguards. Two new switches in the Policy tab,
both off by default, both gated by mcp_policy::SshSafeguards:

  allowOpenSsh: when off, connect_host and spawn_pane(kind=ssh)
    refuse server-side with a clear "ssh-disabled" message pointing
    at the Policy tab. User must open SSH manually via the titlebar
    🔑 picker and toggle 🤖 on to grant Claude access.

  autoAllowSpawnedSsh: when off, an SSH pane Claude spawns starts
    with mcpAllow=false. User must explicitly toggle 🤖 before
    Claude can read scrollback or send keystrokes. The second switch
    is disabled in the UI when the first is off.

The safe-by-default design means a fresh install gives Claude no
ability to autonomously touch SSH — full safety with one click per
level to enable when consciously wanted. Both switches read fresh
per call so policy edits take effect without a server restart.

ErrorBoundary.tsx — last-resort guard against React render
exceptions. Wraps the App root + each MCP panel tab independently
so a bug in one tab doesn't blank the entire app. Shows a small
red error card with the exception message and a "Try again"
button. Caught a serde rename_all bug during PR-3.5 testing where
PolicyTab read policy.sshSafeguards but Rust serialized
ssh_safeguards (snake_case); without the boundary the whole window
went black.

newId() now exported from tree.ts for the splitLeafWith path.
McpPolicy struct gained #[serde(rename_all = "camelCase")] so
sshSafeguards survives the IPC round-trip cleanly; older policy
files without the field still load (serde defaults to safe).
2026-05-26 14:50:06 +01:00

738 lines
24 KiB
TypeScript

//! Splits-tree layout model.
//!
//! The workspace is a binary tree of splits. Internal nodes are HSplit or
//! VSplit with a ratio; leaves are terminal panes. This is the same model
//! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio,
//! both sibling subtrees reflow automatically.
export type NodeId = string;
/** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */
export type Orientation = "h" | "v";
/** What kind of shell a leaf is running. Determines which fields on
* LeafNode are meaningful at spawn time and which spawn-spec the backend
* receives. Migration on deserialize backfills this for pre-shellKind
* workspaces (PowerShell was previously a sentinel `distro` string). */
export type ShellKind = "wsl" | "powershell" | "ssh";
export interface LeafNode {
kind: "leaf";
id: NodeId;
/** Discriminator: which shell-type this pane runs. */
shellKind: ShellKind;
/** WSL distro the pane was spawned against. Only meaningful when
* shellKind === "wsl". */
distro?: string;
/** Working directory the pane was started in. Only meaningful when
* shellKind === "wsl". */
cwd?: string;
/** Saved-host id (see SshHost). Only meaningful when shellKind === "ssh". */
sshHostId?: string;
/** Optional user label shown in the pane toolbar. */
label?: string;
/**
* If true, keystrokes typed in this pane are mirrored to every other
* leaf with `broadcast === true`. Toggle via the 📡 button in the
* pane toolbar.
*/
broadcast?: boolean;
/**
* Per-pane font-size delta from the default ({@link DEFAULT_FONT_SIZE}).
* Bumped by Ctrl+Shift+= / Ctrl+Shift+- / reset by Ctrl+Shift+0.
* Stored as an offset (not absolute) so changing the base default
* later doesn't require migrating saved workspaces.
*/
fontSizeOffset?: number;
/**
* If true, this pane is visible to the MCP server (Claude can list it,
* read its scrollback, etc.). Default-DENY: when undefined or false, the
* MCP surface filters this pane out entirely. Toggled via the per-pane
* MCP chip in the toolbar.
*/
mcpAllow?: boolean;
}
/** Base xterm.js font size in px. Per-leaf offset adds on top of this. */
export const DEFAULT_FONT_SIZE = 13;
/** Hard clamps on `DEFAULT_FONT_SIZE + offset`. */
export const MIN_FONT_SIZE = 6;
export const MAX_FONT_SIZE = 40;
export interface SplitNode {
kind: "split";
id: NodeId;
orientation: Orientation;
/** Size of `a` divided by total. Kept in (0, 1) — clamped on drag. */
ratio: number;
a: TreeNode;
b: TreeNode;
}
export type TreeNode = LeafNode | SplitNode;
export function newId(): NodeId {
return (
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2, 12)
);
}
export function newLeaf(props: Partial<Omit<LeafNode, "kind" | "id">> = {}): LeafNode {
return { kind: "leaf", id: newId(), shellKind: "wsl", ...props };
}
/** Spec for switching a leaf's shell. Discriminated by shellKind. Used by
* {@link setLeafShell}; the helper always swaps the leaf id so the renderer
* remounts XtermPane (kills the old PTY → spawns a fresh one with the new
* spec). */
export type LeafShellSpec =
| { shellKind: "wsl"; distro?: string; cwd?: string }
| { shellKind: "powershell" }
| { shellKind: "ssh"; sshHostId: string };
/**
* Replace the leaf's shell-kind and shell-specific fields, then swap its id
* so the renderer's `key={leaf.id}` block remounts XtermPane (kills the old
* PTY → spawns a fresh one). Metadata like label / broadcast / font-size
* survives.
*/
export function setLeafShell(
root: TreeNode,
leafId: NodeId,
spec: LeafShellSpec,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
const base: LeafNode = {
kind: "leaf",
id: newId(),
shellKind: spec.shellKind,
label: node.label,
broadcast: node.broadcast,
fontSizeOffset: node.fontSizeOffset,
};
if (spec.shellKind === "wsl") {
if (spec.distro !== undefined) base.distro = spec.distro;
if (spec.cwd !== undefined) base.cwd = spec.cwd;
} else if (spec.shellKind === "ssh") {
base.sshHostId = spec.sshHostId;
}
return base;
});
}
export function newSplit(
orientation: Orientation,
a: TreeNode,
b: TreeNode,
ratio = 0.5,
): SplitNode {
return { kind: "split", id: newId(), orientation, ratio, a, b };
}
/** Walk the tree, replacing the node with the given id via `produce`. Returns a new tree. */
export function replaceById(
root: TreeNode,
targetId: NodeId,
produce: (node: TreeNode) => TreeNode,
): TreeNode {
if (root.id === targetId) return produce(root);
if (root.kind === "leaf") return root;
const a = replaceById(root.a, targetId, produce);
const b = replaceById(root.b, targetId, produce);
if (a === root.a && b === root.b) return root;
return { ...root, a, b };
}
/** Split the leaf with the given id. New pane goes on side `b`. */
export function splitLeaf(
root: TreeNode,
leafId: NodeId,
orientation: Orientation,
newLeafProps: Partial<Omit<LeafNode, "kind" | "id">> = {},
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return newSplit(orientation, node, newLeaf(newLeafProps));
});
}
/** Like {@link splitLeaf} but inserts a caller-constructed LeafNode (with a
* predetermined id) rather than minting a fresh one. Used by the MCP
* spawn_pane handler which needs the id up-front so it can wait for the
* matching registerPaneId call before replying to the backend. */
export function splitLeafWith(
root: TreeNode,
leafId: NodeId,
orientation: Orientation,
leaf: LeafNode,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return newSplit(orientation, node, leaf);
});
}
/**
* Remove the leaf with the given id. The other child of its parent split
* takes the parent's place in the tree. Returns null if the closed leaf
* was the entire tree (caller should create a fresh leaf).
*/
export function closeLeaf(root: TreeNode, leafId: NodeId): TreeNode | null {
if (root.id === leafId) return null;
if (root.kind === "leaf") return root;
// If a direct child is the target leaf, collapse this split to the sibling.
if (root.a.kind === "leaf" && root.a.id === leafId) return root.b;
if (root.b.kind === "leaf" && root.b.id === leafId) return root.a;
// Recurse.
const newA = closeLeaf(root.a, leafId);
const newB = closeLeaf(root.b, leafId);
// If either side collapsed to null somehow (target was a split, shouldn't
// happen with current callers but defensive), fall back to the other side.
if (newA === null) return newB ?? root;
if (newB === null) return newA;
if (newA === root.a && newB === root.b) return root;
return { ...root, a: newA, b: newB };
}
/** Find a leaf by id. Returns null if not found. */
export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null {
if (root.kind === "leaf") return root.id === leafId ? root : null;
return findLeaf(root.a, leafId) ?? findLeaf(root.b, leafId);
}
/**
* Swap the WSL distro on a leaf. The leaf gets a **new id** so the rendering
* layer remounts XtermPane — the old PTY is killed and a fresh one spawns
* against the new distro. Also forces shellKind back to "wsl" if the leaf
* had been a non-WSL kind (which is what the existing per-pane dropdown
* does when the user picks a WSL distro entry).
*/
export function changeDistro(
root: TreeNode,
leafId: NodeId,
distro: string,
): TreeNode {
return setLeafShell(root, leafId, { shellKind: "wsl", distro });
}
/** Set or clear a leaf's label. Does NOT remount (label is metadata only). */
export function changeLabel(
root: TreeNode,
leafId: NodeId,
label: string | undefined,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
const trimmed = label?.trim();
return { ...node, label: trimmed ? trimmed : undefined };
});
}
// ---- preset layouts --------------------------------------------------------
type LeafDefaults = Partial<Omit<LeafNode, "kind" | "id">>;
export function presetSingle(d: LeafDefaults = {}): TreeNode {
return newLeaf(d);
}
export function presetTwoColumns(d: LeafDefaults = {}): TreeNode {
return newSplit("h", newLeaf(d), newLeaf(d));
}
export function presetThreeColumns(d: LeafDefaults = {}): TreeNode {
// Even thirds: outer split at 1/3, inner split at 1/2.
return newSplit(
"h",
newLeaf(d),
newSplit("h", newLeaf(d), newLeaf(d), 0.5),
1 / 3,
);
}
export function presetTwoRows(d: LeafDefaults = {}): TreeNode {
return newSplit("v", newLeaf(d), newLeaf(d));
}
export function presetTwoByTwo(d: LeafDefaults = {}): TreeNode {
const row = () => newSplit("h", newLeaf(d), newLeaf(d));
return newSplit("v", row(), row());
}
/** Number of leaves in the tree. */
export function leafCount(root: TreeNode): number {
if (root.kind === "leaf") return 1;
return leafCount(root.a) + leafCount(root.b);
}
/** Iterate all leaves in left-to-right order. */
export function* walkLeaves(root: TreeNode): Generator<LeafNode> {
if (root.kind === "leaf") {
yield root;
} else {
yield* walkLeaves(root.a);
yield* walkLeaves(root.b);
}
}
/** Toggle a leaf's broadcast flag. Metadata-only — does NOT swap the id, so the pane is not respawned. */
export function toggleBroadcast(root: TreeNode, leafId: NodeId): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return { ...node, broadcast: !node.broadcast };
});
}
/** Toggle a leaf's mcpAllow flag. Metadata-only — does NOT swap the id.
* Drives whether the MCP server includes this pane in its surface. */
export function toggleMcpAllow(root: TreeNode, leafId: NodeId): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return { ...node, mcpAllow: !node.mcpAllow };
});
}
/** Compute the actual pixel font size from a leaf's offset, clamped to
* [MIN_FONT_SIZE, MAX_FONT_SIZE]. */
export function resolveFontSize(offset: number | undefined): number {
const px = DEFAULT_FONT_SIZE + (offset ?? 0);
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, px));
}
/** Apply a font-size change to one leaf. Internal helper; returns the
* same reference when nothing changes so callers can short-circuit. */
function adjustOneFontSize(leaf: LeafNode, delta: number | null): LeafNode {
if (delta === null) {
if (leaf.fontSizeOffset === undefined) return leaf;
const next: LeafNode = { ...leaf };
delete next.fontSizeOffset;
return next;
}
const cur = leaf.fontSizeOffset ?? 0;
const nextPx = resolveFontSize(cur + delta);
const nextOffset = nextPx - DEFAULT_FONT_SIZE;
if (nextOffset === cur) return leaf;
if (nextOffset === 0) {
const next: LeafNode = { ...leaf };
delete next.fontSizeOffset;
return next;
}
return { ...leaf, fontSizeOffset: nextOffset };
}
/** Adjust a single leaf's font-size offset by `delta` (positive = bigger).
* Pass `delta = null` to reset back to the default. Metadata-only — does
* NOT swap the id, so the PTY keeps running. */
export function adjustFontSize(
root: TreeNode,
leafId: NodeId,
delta: number | null,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return adjustOneFontSize(node, delta);
});
}
/** Adjust EVERY leaf's font-size offset by the same `delta` (or reset all
* to default with `delta = null`). Independent per-pane offsets stay
* independent — we just shift each by the same amount. */
export function adjustAllFontSizes(root: TreeNode, delta: number | null): TreeNode {
if (root.kind === "leaf") return adjustOneFontSize(root, delta);
const a = adjustAllFontSizes(root.a, delta);
const b = adjustAllFontSizes(root.b, delta);
if (a === root.a && b === root.b) return root;
return { ...root, a, b };
}
/**
* Reshape the tree into the structure produced by `preset`, but PRESERVE
* existing leaves (and their PTYs) by copying their id/distro/cwd/label/
* broadcast into the preset's slots, in DFS order.
*
* - If the preset has more slots than existing leaves, the extra slots stay
* as their freshly-created (empty) leaves — those panes will spawn new
* shells.
* - If the preset has fewer slots than existing leaves, the overflow leaves
* are returned in `dropped` so the caller can kill their PTYs.
*
* Split ratios reset to the preset's defaults (0.5 — the user's previous
* resize work is discarded; that's the point of "apply preset").
*/
export function reshapeToPreset(
existing: TreeNode,
preset: (d: LeafDefaults) => TreeNode,
defaults: LeafDefaults,
): { tree: TreeNode; dropped: NodeId[] } {
const existingLeaves = Array.from(walkLeaves(existing));
const tree = preset(defaults);
const slots = Array.from(walkLeaves(tree));
const dropped: NodeId[] = [];
for (let i = 0; i < slots.length; i++) {
const src = existingLeaves[i];
if (!src) break;
const slot = slots[i];
slot.id = src.id;
slot.shellKind = src.shellKind;
if (src.distro !== undefined) slot.distro = src.distro;
if (src.cwd !== undefined) slot.cwd = src.cwd;
if (src.sshHostId !== undefined) slot.sshHostId = src.sshHostId;
if (src.label !== undefined) slot.label = src.label;
if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset;
if (src.mcpAllow !== undefined) slot.mcpAllow = src.mcpAllow;
}
for (let i = slots.length; i < existingLeaves.length; i++) {
dropped.push(existingLeaves[i].id);
}
return { tree, dropped };
}
/** Force every leaf's broadcast flag to `on`. Returns the same root reference
* when nothing actually changed, so callers can skip a state update if so. */
export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode {
if (root.kind === "leaf") {
if (!!root.broadcast === on) return root;
return { ...root, broadcast: on };
}
const a = setAllBroadcast(root.a, on);
const b = setAllBroadcast(root.b, on);
if (a === root.a && b === root.b) return root;
return { ...root, a, b };
}
/** Minimum width/height (px) we allow a pane to shrink to. Just enough for
* the toolbar + a few cols/rows of usable terminal. Used by the split
* handler (to refuse subdividing a pane that's already too small) and by
* the gutter drag (to clamp ratios so neither child drops below this). */
export const MIN_PANE_PX = 180;
// --- flat layout (for absolute-positioned rendering) ------------------------
/** Normalised bounding box: top/left/width/height as fractions [0, 1]. */
export interface Box {
top: number;
left: number;
width: number;
height: number;
}
/** A leaf rendered as a flat sibling: its current LeafNode plus the box
* it occupies in the container. */
export interface LeafSlot {
leaf: LeafNode;
box: Box;
}
/** A draggable gutter at a split boundary. `box` is where to render the
* draggable strip; `parentBox` is the area the gutter divides (needed to
* convert pointer position → ratio). */
export interface GutterInfo {
splitId: NodeId;
orientation: Orientation;
ratio: number;
box: Box;
parentBox: Box;
}
/** Walk the tree and produce a flat list of leaf slots + draggable gutters.
* Renderer uses these to position all leaves as siblings in the DOM, which
* lets React preserve component instances (and thus PTYs) across any tree
* reshape — splits, closes, presets, etc. */
export function flattenLayout(
root: TreeNode,
box: Box = { top: 0, left: 0, width: 1, height: 1 },
): { leaves: LeafSlot[]; gutters: GutterInfo[] } {
if (root.kind === "leaf") {
return { leaves: [{ leaf: root, box }], gutters: [] };
}
const isH = root.orientation === "h";
const r = root.ratio;
let boxA: Box;
let boxB: Box;
let gutter: GutterInfo;
if (isH) {
const splitPos = box.width * r;
boxA = { top: box.top, left: box.left, width: splitPos, height: box.height };
boxB = {
top: box.top,
left: box.left + splitPos,
width: box.width - splitPos,
height: box.height,
};
gutter = {
splitId: root.id,
orientation: "h",
ratio: r,
box: {
top: box.top,
left: box.left + splitPos,
width: 0,
height: box.height,
},
parentBox: box,
};
} else {
const splitPos = box.height * r;
boxA = { top: box.top, left: box.left, width: box.width, height: splitPos };
boxB = {
top: box.top + splitPos,
left: box.left,
width: box.width,
height: box.height - splitPos,
};
gutter = {
splitId: root.id,
orientation: "v",
ratio: r,
box: {
top: box.top + splitPos,
left: box.left,
width: box.width,
height: 0,
},
parentBox: box,
};
}
const a = flattenLayout(root.a, boxA);
const b = flattenLayout(root.b, boxB);
return {
leaves: [...a.leaves, ...b.leaves],
gutters: [gutter, ...a.gutters, ...b.gutters],
};
}
/** Update a split's ratio by its id. */
export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number): TreeNode {
return replaceById(root, splitId, (node) => {
if (node.kind !== "split") return node;
return { ...node, ratio };
});
}
/**
* Promote the given leaf out one level in the tree — the keyboard-driven
* equivalent of the "drag past sibling" gesture. Given:
*
* L's parent split P, P's parent split G (must be perpendicular to P)
*
* restructure so L becomes a direct sibling of the combined (P's other
* child + G's other child) subtree:
*
* HSplit(a, VSplit(b, c)) ──(promote c)──> VSplit(HSplit(a, b), c)
* HSplit(a, VSplit(b, c)) ──(promote b)──> VSplit(b, HSplit(a, c))
*
* Self-inverse: promoting L, then promoting the leaf adjacent to L in the
* combined subtree, returns the original tree. Ratios from P and G carry
* across so the visible layout is approximately preserved.
*
* Returns `null` when the gesture can't apply: leaf not found, leaf is
* the root (no parent), parent is the root (no grandparent), or
* parent's orientation matches grandparent's (no perpendicular promotion
* available — same-axis nesting doesn't change the workspace shape).
*/
export function promoteLeaf(root: TreeNode, leafId: NodeId): TreeNode | null {
const found = findLeafWithAncestors(root, leafId);
if (!found) return null;
const { l, p, g, isLFirstInP, isPFirstInG } = found;
if (p.orientation === g.orientation) return null;
const siblingOfL = isLFirstInP ? p.b : p.a;
const siblingOfP = isPFirstInG ? g.b : g.a;
// Combined keeps G's orientation; sibling-of-P stays on its original
// G-side so we don't accidentally mirror unrelated panes.
const combined: SplitNode = {
kind: "split",
id: newId(),
orientation: g.orientation,
ratio: g.ratio,
a: isPFirstInG ? siblingOfL : siblingOfP,
b: isPFirstInG ? siblingOfP : siblingOfL,
};
// New outer keeps P's orientation; L stays on its original P-side.
const newOuter: SplitNode = {
kind: "split",
id: newId(),
orientation: p.orientation,
ratio: p.ratio,
a: isLFirstInP ? l : combined,
b: isLFirstInP ? combined : l,
};
return replaceById(root, g.id, () => newOuter);
}
/** Locate a leaf and its parent + grandparent splits. Returns null if
* the leaf doesn't exist or doesn't have two ancestor splits. */
function findLeafWithAncestors(
root: TreeNode,
leafId: NodeId,
): {
l: LeafNode;
p: SplitNode;
g: SplitNode;
isLFirstInP: boolean;
isPFirstInG: boolean;
} | null {
if (root.kind !== "split") return null;
// root is the grandparent candidate (G). Look at each direct child of
// root — if that child is a split (P), check P's children for the leaf.
for (const isPFirstInG of [true, false]) {
const p = isPFirstInG ? root.a : root.b;
if (p.kind !== "split") continue;
if (p.a.kind === "leaf" && p.a.id === leafId) {
return { l: p.a, p, g: root, isLFirstInP: true, isPFirstInG };
}
if (p.b.kind === "leaf" && p.b.id === leafId) {
return { l: p.b, p, g: root, isLFirstInP: false, isPFirstInG };
}
}
// Recurse on root's children to find deeper L-P-G triples.
return (
findLeafWithAncestors(root.a, leafId) ??
findLeafWithAncestors(root.b, leafId)
);
}
export type Direction = "left" | "right" | "up" | "down";
/** Spatial pane navigation: given an active leaf, find the nearest neighbor
* in the requested direction. Used for Ctrl+Shift+Arrow shortcuts. */
export function findNeighborInDirection(
leaves: LeafSlot[],
fromLeafId: NodeId,
direction: Direction,
): NodeId | null {
const from = leaves.find((s) => s.leaf.id === fromLeafId);
if (!from) return null;
const fromCenter = {
x: from.box.left + from.box.width / 2,
y: from.box.top + from.box.height / 2,
};
const EPS = 1e-3;
let best: { id: NodeId; perpDist: number; primaryDist: number } | null = null;
for (const slot of leaves) {
if (slot.leaf.id === fromLeafId) continue;
const center = {
x: slot.box.left + slot.box.width / 2,
y: slot.box.top + slot.box.height / 2,
};
let primary: number;
let perp: number;
switch (direction) {
case "right":
if (slot.box.left < from.box.left + from.box.width - EPS) continue;
primary = center.x - fromCenter.x;
perp = Math.abs(center.y - fromCenter.y);
break;
case "left":
if (slot.box.left + slot.box.width > from.box.left + EPS) continue;
primary = fromCenter.x - center.x;
perp = Math.abs(center.y - fromCenter.y);
break;
case "down":
if (slot.box.top < from.box.top + from.box.height - EPS) continue;
primary = center.y - fromCenter.y;
perp = Math.abs(center.x - fromCenter.x);
break;
case "up":
if (slot.box.top + slot.box.height > from.box.top + EPS) continue;
primary = fromCenter.y - center.y;
perp = Math.abs(center.x - fromCenter.x);
break;
}
if (
best === null ||
perp < best.perpDist ||
(perp === best.perpDist && primary < best.primaryDist)
) {
best = { id: slot.leaf.id, perpDist: perp, primaryDist: primary };
}
}
return best?.id ?? null;
}
/** Swap two leaves' tree positions. Each leaf carries its own data
* (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay
* alive because React keys on leaf.id and our renderer is flat. */
export function swapLeaves(root: TreeNode, idA: NodeId, idB: NodeId): TreeNode {
if (idA === idB) return root;
const a = findLeaf(root, idA);
const b = findLeaf(root, idB);
if (!a || !b) return root;
function walk(n: TreeNode): TreeNode {
if (n.kind === "leaf") {
if (n.id === idA) return b!;
if (n.id === idB) return a!;
return n;
}
const na = walk(n.a);
const nb = walk(n.b);
if (na === n.a && nb === n.b) return n;
return { ...n, a: na, b: nb };
}
return walk(root);
}
export function serialize(root: TreeNode): string {
return JSON.stringify(root);
}
/** Parse JSON back to a tree. Returns null on invalid input. Pre-shellKind
* workspaces are migrated in place: leaves without `shellKind` get one
* inferred from the legacy `distro` sentinel (`"PowerShell"` → powershell,
* anything else → wsl). */
export function deserialize(json: string): TreeNode | null {
try {
const parsed = JSON.parse(json);
if (!isTreeNode(parsed)) return null;
return migrateLegacyLeaves(parsed);
} catch {
return null;
}
}
/** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */
const LEGACY_POWERSHELL_DISTRO = "PowerShell";
function migrateLegacyLeaves(node: TreeNode): TreeNode {
if (node.kind === "leaf") {
if (node.shellKind) return node;
if (node.distro === LEGACY_POWERSHELL_DISTRO) {
const { distro: _distro, ...rest } = node;
return { ...rest, shellKind: "powershell" };
}
return { ...node, shellKind: "wsl" };
}
const a = migrateLegacyLeaves(node.a);
const b = migrateLegacyLeaves(node.b);
if (a === node.a && b === node.b) return node;
return { ...node, a, b };
}
function isTreeNode(x: unknown): x is TreeNode {
if (typeof x !== "object" || x === null) return false;
const o = x as Record<string, unknown>;
if (typeof o.id !== "string") return false;
if (o.kind === "leaf") return true;
if (o.kind === "split") {
return (
(o.orientation === "h" || o.orientation === "v") &&
typeof o.ratio === "number" &&
isTreeNode(o.a) &&
isTreeNode(o.b)
);
}
return false;
}