Flat-list layout: render leaves as siblings keyed by id

The fix for the real preset bug: previously, presetSingle/2H/3H/2V/2×2
appeared to preserve panes (we copied id/distro/cwd/label/broadcast
into the preset's slots), but React's reconciliation tore down every
LeafPane and re-mounted it because the tree structure changed —
killing all PTYs and spawning fresh shells. The "preservation" was
data-only; the React components didn't survive.

Solution: stop rendering the Pane → SplitNode → LeafPane recursion.
Walk the tree to produce a FLAT layout of `{leaf, box}` entries (each
box is top/left/width/height as fractions 0–1). Render all leaves as
siblings of a relative-positioned container, each absolutely
positioned by its box. Key each one by leaf.id — React preserves the
component (and its XtermPane → PTY) across any tree reshape; only the
inline style changes.

Gutters render as separate sibling overlays at the split boundaries,
each with its own pointer handlers. Dragging mutates the split's
ratio via `updateSplitRatio(tree, splitId, r)`; the layout
recomputes; leaf boxes change; nothing remounts.

Now: clicking 2×2 on 4 stacked panes keeps all 4 shells alive and
just rearranges them into the grid. Same for any preset that doesn't
overflow.

Side benefit: removed the recursive Pane.tsx + SplitNode.tsx + their
CSS. The render path is now straightforward, no recursion, easier to
reason about.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 19:39:58 +01:00
parent 8c3af8f9ee
commit c4747546e0
7 changed files with 272 additions and 134 deletions

View file

@ -253,6 +253,109 @@ export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode {
return { ...root, a, b };
}
// --- 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 };
});
}
export function serialize(root: TreeNode): string {
return JSON.stringify(root);
}