Migrate frontend from Svelte 5 to React 18
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>
This commit is contained in:
parent
e9015b2790
commit
774b8633dc
32 changed files with 2087 additions and 1825 deletions
79
src/lib/layout/SplitNode.tsx
Normal file
79
src/lib/layout/SplitNode.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue