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,
|
toggleBroadcast as toggleBroadcastInTree,
|
||||||
setAllBroadcast,
|
setAllBroadcast,
|
||||||
reshapeToPreset,
|
reshapeToPreset,
|
||||||
|
flattenLayout,
|
||||||
|
updateSplitRatio,
|
||||||
serialize,
|
serialize,
|
||||||
deserialize,
|
deserialize,
|
||||||
presetSingle,
|
presetSingle,
|
||||||
|
|
@ -32,10 +34,12 @@ import {
|
||||||
presetTwoByTwo,
|
presetTwoByTwo,
|
||||||
} from "./lib/layout/tree";
|
} from "./lib/layout/tree";
|
||||||
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
|
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 Notifications, { type Toast } from "./components/Notifications";
|
||||||
import Palette from "./components/Palette";
|
import Palette from "./components/Palette";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
import "./lib/layout/Gutter.css";
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
|
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
|
||||||
const SAVE_DEBOUNCE_MS = 500;
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
|
|
@ -299,6 +303,16 @@ export default function App() {
|
||||||
[paletteOpen, tree],
|
[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) -------------------------
|
// ---- global broadcast state (derived from tree) -------------------------
|
||||||
const broadcastStats = useMemo(() => {
|
const broadcastStats = useMemo(() => {
|
||||||
let on = 0;
|
let on = 0;
|
||||||
|
|
@ -395,10 +409,32 @@ export default function App() {
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="pane-wrap">
|
<div className="pane-wrap" ref={paneWrapRef}>
|
||||||
{ready && (
|
{ready && (
|
||||||
<OrchestrationProvider value={orch}>
|
<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>
|
</OrchestrationProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 };
|
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 {
|
export function serialize(root: TreeNode): string {
|
||||||
return JSON.stringify(root);
|
return JSON.stringify(root);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue