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
42
src/App.tsx
42
src/App.tsx
|
|
@ -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
32
src/lib/layout/Gutter.css
Normal 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
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue