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

View file

@ -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>