After hours of fighting Svelte 5's prop-reactivity through the
recursive Pane → SplitNode → LeafPane chain (props captured at
mount, never updated; context+getter pattern crashed; DOM-direct
workarounds created zombie-split click-intercept bugs), we
checkpointed the Svelte version (branch svelte-archive at e9015b2,
tarball at D:\archives\tiletopia-svelte-2026-05-22.tar.gz) and
rewrote the frontend in React.
Kept verbatim:
- All of src-tauri/ (Rust backend, Tauri config, icons)
- scripts/ (make-icon.py, release.sh)
- README.md, CLAUDE.md, memory.md
- src/lib/layout/tree.ts (pure TS — 43 tests still pass)
- src/ipc.ts (Tauri command wrappers)
Rewrote in React:
- src/App.tsx (top-level state via useState, OrchestrationProvider
for descendants via React.Context)
- src/lib/layout/orchestration.tsx (React Context API for shared
state — known-reliable reactivity, no Svelte 5 wall)
- src/lib/layout/Pane.tsx (recursive dispatcher)
- src/lib/layout/SplitNode.tsx (draggable gutter, local ratio state)
- src/lib/layout/LeafPane.tsx (toolbar + XtermPane)
- src/components/XtermPane.tsx (xterm.js wrapper, refs for callbacks)
- src/components/Notifications.tsx, Palette.tsx
Build: Vite + @vitejs/plugin-react. TypeScript strict. Same Tauri 2
config. Verified: pnpm check (clean), pnpm test (43/43 pass).
Not yet verified: pnpm tauri dev — that requires the Windows host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
79 lines
2.8 KiB
TypeScript
79 lines
2.8 KiB
TypeScript
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>
|
|
);
|
|
}
|