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>
98 lines
3.1 KiB
TypeScript
98 lines
3.1 KiB
TypeScript
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}
|
||
/>
|
||
);
|
||
}
|