Add M2 splits-tree layout
- src/lib/layout/tree.ts: pure helpers + types (newLeaf, splitLeaf, closeLeaf, replaceById, serialize/deserialize with shape-checking). - SplitNode.svelte: flex container with pointer-captured gutter drag. - LeafPane.svelte: per-pane toolbar (split-right ⇥, split-down ⇣, close ×) over the existing XtermPane. - Pane.svelte: recursive dispatcher between SplitNode and LeafPane, keyed on leaf.id so swaps unmount XtermPane cleanly (kills PTY). - App.svelte: tree-as-state with split/close handlers, auto-save to localStorage on every \$effect tick. Titlebar shows clickable distro buttons setting the default for new panes; existing panes keep theirs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9beab64e00
commit
efcdf6a9ce
7 changed files with 531 additions and 59 deletions
146
src/lib/layout/tree.ts
Normal file
146
src/lib/layout/tree.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
//! 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";
|
||||
|
||||
export interface LeafNode {
|
||||
kind: "leaf";
|
||||
id: NodeId;
|
||||
/** WSL distro the pane was spawned against. */
|
||||
distro?: string;
|
||||
/** Working directory the pane was started in. Not currently used at spawn time but preserved for future. */
|
||||
cwd?: string;
|
||||
/** Optional user label shown in the pane toolbar. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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(), ...props };
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
export function serialize(root: TreeNode): string {
|
||||
return JSON.stringify(root);
|
||||
}
|
||||
|
||||
/** Parse JSON back to a tree. Returns null on invalid input. */
|
||||
export function deserialize(json: string): TreeNode | null {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (!isTreeNode(parsed)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue