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).
738 lines
24 KiB
TypeScript
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;
|
|
}
|