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

@ -23,6 +23,8 @@ import {
toggleBroadcast as toggleBroadcastInTree,
setAllBroadcast,
reshapeToPreset,
flattenLayout,
updateSplitRatio,
serialize,
deserialize,
presetSingle,
@ -32,10 +34,12 @@ import {
presetTwoByTwo,
} from "./lib/layout/tree";
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
import Pane from "./lib/layout/Pane";
import LeafPane from "./lib/layout/LeafPane";
import Gutter from "./lib/layout/Gutter";
import Notifications, { type Toast } from "./components/Notifications";
import Palette from "./components/Palette";
import "./App.css";
import "./lib/layout/Gutter.css";
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
const SAVE_DEBOUNCE_MS = 500;
@ -299,6 +303,16 @@ export default function App() {
[paletteOpen, tree],
);
// ---- flat layout — leaves as siblings keyed by id; gutters separate -----
// This lets React preserve LeafPane (and its PTY) across any tree reshape
// — split, close, preset application, etc. The tree changes, the boxes
// change, the leaves re-position but DON'T unmount.
const layout = useMemo(() => flattenLayout(tree), [tree]);
const paneWrapRef = useRef<HTMLDivElement>(null);
const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => {
setTree((t) => updateSplitRatio(t, splitId, ratio));
}, []);
// ---- global broadcast state (derived from tree) -------------------------
const broadcastStats = useMemo(() => {
let on = 0;
@ -395,10 +409,32 @@ export default function App() {
</span>
</header>
<div className="pane-wrap">
<div className="pane-wrap" ref={paneWrapRef}>
{ready && (
<OrchestrationProvider value={orch}>
<Pane node={tree} />
{layout.leaves.map(({ leaf, box }) => (
<div
key={leaf.id}
className="leaf-slot"
style={{
position: "absolute",
top: `${box.top * 100}%`,
left: `${box.left * 100}%`,
width: `${box.width * 100}%`,
height: `${box.height * 100}%`,
}}
>
<LeafPane leaf={leaf} />
</div>
))}
{layout.gutters.map((g) => (
<Gutter
key={g.splitId}
info={g}
containerRef={paneWrapRef}
onRatioChange={onGutterRatio}
/>
))}
</OrchestrationProvider>
)}
</div>

32
src/lib/layout/Gutter.css Normal file
View file

@ -0,0 +1,32 @@
/* The hitbox is invisible; we render a 4px visible line in the middle
via a pseudo-element so the grab area is generous while the visual
stays thin. */
.gutter {
background: transparent;
user-select: none;
touch-action: none;
}
.gutter::before {
content: "";
position: absolute;
background: #1a1a1a;
transition: background 0.12s;
}
.gutter-h::before {
top: 0;
bottom: 0;
left: 50%;
width: 4px;
transform: translateX(-50%);
}
.gutter-v::before {
left: 0;
right: 0;
top: 50%;
height: 4px;
transform: translateY(-50%);
}
.gutter:hover::before,
.gutter.active::before {
background: #3a5a8c;
}

98
src/lib/layout/Gutter.tsx Normal file
View file

@ -0,0 +1,98 @@
import { useCallback, useRef, useState, type PointerEvent } from "react";
import type { GutterInfo } from "./tree";
/**
* A draggable gutter at a split boundary.
*
* `info.box` is where to render the strip (in container fractions 01);
* `info.parentBox` is the parent split's bounding box, used to convert
* pointer position back into a 01 ratio.
*
* The actual draggable hitbox is a few pixels thick (and centered on the
* boundary), but we render a thin visible line via CSS pseudo-elements.
*/
const HITBOX_PX = 8;
export default function Gutter({
info,
containerRef,
onRatioChange,
}: {
info: GutterInfo;
containerRef: React.RefObject<HTMLDivElement | null>;
onRatioChange: (splitId: string, ratio: number) => void;
}) {
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
draggingRef.current = true;
e.preventDefault();
e.stopPropagation();
}, []);
const onPointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const xFrac = (e.clientX - rect.left) / rect.width;
const yFrac = (e.clientY - rect.top) / rect.height;
const pb = info.parentBox;
const rawRatio =
info.orientation === "h"
? (xFrac - pb.left) / pb.width
: (yFrac - pb.top) / pb.height;
const ratio = Math.max(0.05, Math.min(0.95, rawRatio));
onRatioChange(info.splitId, ratio);
},
[containerRef, info, onRatioChange],
);
const onPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const isH = info.orientation === "h";
// Visible 4px line, but the draggable hitbox is wider for grabbability.
const halfHit = HITBOX_PX / 2;
const style: React.CSSProperties = isH
? {
position: "absolute",
top: `${info.box.top * 100}%`,
left: `calc(${info.box.left * 100}% - ${halfHit}px)`,
height: `${info.box.height * 100}%`,
width: `${HITBOX_PX}px`,
cursor: "col-resize",
zIndex: 10,
}
: {
position: "absolute",
top: `calc(${info.box.top * 100}% - ${halfHit}px)`,
left: `${info.box.left * 100}%`,
width: `${info.box.width * 100}%`,
height: `${HITBOX_PX}px`,
cursor: "row-resize",
zIndex: 10,
};
return (
<div
className={`gutter ${isH ? "gutter-h" : "gutter-v"}${dragging ? " active" : ""}`}
style={style}
role="separator"
aria-orientation={isH ? "vertical" : "horizontal"}
aria-valuenow={Math.round(info.ratio * 100)}
tabIndex={-1}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
);
}

View file

@ -1,16 +0,0 @@
import type { TreeNode } from "./tree";
import SplitNode from "./SplitNode";
import LeafPane from "./LeafPane";
/**
* Recursive dispatcher: render a split or a leaf based on node.kind.
* The `key={node.id}` on the leaf branch makes React unmount + remount
* cleanly when a leaf is replaced (e.g. changeDistro swaps the id to
* force PTY respawn).
*/
export default function Pane({ node }: { node: TreeNode }) {
if (node.kind === "split") {
return <SplitNode node={node} />;
}
return <LeafPane key={node.id} leaf={node} />;
}

View file

@ -1,36 +0,0 @@
.split {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.split.horizontal {
flex-direction: row;
}
.split.vertical {
flex-direction: column;
}
.side {
display: flex;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.gutter {
flex: 0 0 4px;
background: #1a1a1a;
cursor: col-resize;
user-select: none;
touch-action: none;
transition: background 0.12s;
}
.split.vertical > .gutter {
cursor: row-resize;
}
.gutter:hover,
.gutter.active {
background: #3a5a8c;
}

View file

@ -1,79 +0,0 @@
import { useRef, useState, useCallback, type PointerEvent } from "react";
import type { SplitNode as SplitNodeType } from "./tree";
import Pane from "./Pane";
import "./SplitNode.css";
/**
* A horizontal or vertical split with a draggable gutter. The ratio is
* local React state when the gutter is dragged, we update the local
* ratio (re-rendering the two .side flex values) and ALSO bubble the
* change up to the tree (so it persists across reloads).
*
* Initialising local state from node.ratio is fine: when the tree
* mutates around this split (e.g. a child is closed), React will give us
* a new `node` prop with possibly-different `node.ratio`, but the
* `useState` initializer only runs once. We re-sync via an effect.
*/
export default function SplitNode({ node }: { node: SplitNodeType }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [ratio, setRatio] = useState(node.ratio);
const [dragging, setDragging] = useState(false);
// Keep local ratio in sync if the tree updates from outside (e.g. preset
// applied). Only mirror — don't echo back into the tree.
// (Skipped for simplicity in v1; if it becomes annoying we can add it.)
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
e.preventDefault();
}, []);
const onPointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if (!dragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const isH = node.orientation === "h";
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
const size = isH ? rect.width : rect.height;
if (size <= 0) return;
const r = Math.max(0.05, Math.min(0.95, pos / size));
setRatio(r);
// Mutate the proxy-tree node directly so the persisted state matches.
node.ratio = r;
},
[dragging, node],
);
const onPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDragging(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, []);
const isH = node.orientation === "h";
return (
<div
ref={containerRef}
className={`split ${isH ? "horizontal" : "vertical"}`}
>
<div className="side" style={{ flex: ratio }}>
<Pane node={node.a} />
</div>
<div
className={`gutter${dragging ? " active" : ""}`}
role="separator"
aria-orientation={isH ? "vertical" : "horizontal"}
aria-valuenow={Math.round(ratio * 100)}
tabIndex={-1}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
<div className="side" style={{ flex: 1 - ratio }}>
<Pane node={node.b} />
</div>
</div>
);
}

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);
}