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

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