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:
parent
8c3af8f9ee
commit
c4747546e0
7 changed files with 272 additions and 134 deletions
98
src/lib/layout/Gutter.tsx
Normal file
98
src/lib/layout/Gutter.tsx
Normal 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 0–1);
|
||||
* `info.parentBox` is the parent split's bounding box, used to convert
|
||||
* pointer position back into a 0–1 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue