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:
megaproxy 2026-05-22 18:05:05 +01:00
parent e9015b2790
commit 774b8633dc
32 changed files with 2087 additions and 1825 deletions

263
src/lib/layout/LeafPane.tsx Normal file
View file

@ -0,0 +1,263 @@
import {
useState,
useEffect,
useRef,
useCallback,
type KeyboardEvent,
type MouseEvent,
} from "react";
import type { LeafNode } from "./tree";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
import "./LeafPane.css";
const IDLE_THRESHOLD_MS = 5000;
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const orch = useOrchestration();
const isActive = orch.activeLeafId === leaf.id;
const isBroadcasting = !!leaf.broadcast;
// ---- status (from XtermPane) -------------------------------------------
const [status, setStatus] = useState("starting…");
const [statusOk, setStatusOk] = useState(true);
// ---- label editing -----------------------------------------------------
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState("");
const labelInputRef = useRef<HTMLInputElement | null>(null);
const startEditLabel = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setLabelDraft(leaf.label ?? "");
setEditingLabel(true);
// Focus on next tick so input is mounted
queueMicrotask(() => labelInputRef.current?.select());
},
[leaf.label],
);
const commitLabel = useCallback(() => {
if (!editingLabel) return;
orch.setLabel(leaf.id, labelDraft);
setEditingLabel(false);
}, [editingLabel, orch, leaf.id, labelDraft]);
const cancelLabel = useCallback(() => setEditingLabel(false), []);
const onLabelKey = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitLabel();
} else if (e.key === "Escape") {
e.preventDefault();
cancelLabel();
}
},
[commitLabel, cancelLabel],
);
// ---- distro popover ----------------------------------------------------
const [distroOpen, setDistroOpen] = useState(false);
const toggleDistroMenu = useCallback((e: MouseEvent) => {
e.stopPropagation();
setDistroOpen((v) => !v);
}, []);
const pickDistro = useCallback(
(d: string) => {
setDistroOpen(false);
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
},
[orch, leaf.id, leaf.distro],
);
// Dismiss popover on outside click
useEffect(() => {
if (!distroOpen) return;
const onDocClick = () => setDistroOpen(false);
window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick);
}, [distroOpen]);
// ---- idle detection ----------------------------------------------------
const lastDataTimeRef = useRef(Date.now());
const notifiedThisIdleRef = useRef(false);
const onDataReceived = useCallback(() => {
lastDataTimeRef.current = Date.now();
notifiedThisIdleRef.current = false;
}, []);
useEffect(() => {
const id = window.setInterval(() => {
if (notifiedThisIdleRef.current) return;
const dt = Date.now() - lastDataTimeRef.current;
if (dt >= IDLE_THRESHOLD_MS) {
notifiedThisIdleRef.current = true;
const name = leaf.label ?? leaf.distro ?? "pane";
orch.notify(`${name} is idle`);
}
}, 1000);
return () => clearInterval(id);
}, [leaf.label, leaf.distro, orch]);
// ---- broadcast ---------------------------------------------------------
const onTerminalInput = useCallback(
(b64: string) => {
if (isBroadcasting) orch.broadcastFrom(leaf.id, b64);
},
[isBroadcasting, orch, leaf.id],
);
// ---- focus / active highlighting ---------------------------------------
const [focusTrigger, setFocusTrigger] = useState(0);
// When this leaf becomes active, bump focusTrigger so XtermPane refocuses.
useEffect(() => {
if (isActive) setFocusTrigger((n) => n + 1);
}, [isActive]);
const onPaneClick = useCallback(() => {
orch.setActive(leaf.id);
}, [orch, leaf.id]);
const onPaneSpawned = useCallback(
(paneId: number) => {
orch.registerPaneId(leaf.id, paneId);
},
[orch, leaf.id],
);
// Unregister on unmount
useEffect(() => {
return () => orch.registerPaneId(leaf.id, null);
}, [orch, leaf.id]);
const onXtermFocus = useCallback(() => orch.setActive(leaf.id), [orch, leaf.id]);
const onStatus = useCallback((msg: string, ok: boolean) => {
setStatus(msg);
setStatusOk(ok);
}, []);
const labelText = leaf.label ?? "(unnamed)";
return (
<div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}`}
role="group"
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
data-leaf-id={leaf.id}
onPointerDown={onPaneClick}
>
<div className="pane-toolbar">
{editingLabel ? (
<input
ref={labelInputRef}
className="label-input"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onKeyDown={onLabelKey}
onBlur={commitLabel}
placeholder="(label)"
/>
) : (
<button
className="pane-label"
onClick={startEditLabel}
title="Click to rename pane"
>
{labelText}
</button>
)}
<span className="distro-wrap">
<button
className="distro-chip"
onClick={toggleDistroMenu}
title="Change distro (respawns the pane)"
>
{leaf.distro ?? "(default)"}
</button>
{distroOpen && (
<div
className="distro-menu"
role="menu"
onClick={(e) => e.stopPropagation()}
>
{orch.distros.map((d) => (
<button
key={d}
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`}
onClick={() => pickDistro(d)}
>
{d}
</button>
))}
</div>
)}
</span>
<button
className={`bcast-chip${isBroadcasting ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.toggleBroadcast(leaf.id);
}}
title={
isBroadcasting
? "Broadcasting (click to leave group)"
: "Click to broadcast input to other broadcast panes"
}
aria-pressed={isBroadcasting ? "true" : "false"}
>
📡
</button>
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
<span className="pane-actions">
<button
className="pane-btn"
title="Split right"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "h");
}}
aria-label="Split right"
>
</button>
<button
className="pane-btn"
title="Split down"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "v");
}}
aria-label="Split down"
>
</button>
<button
className="pane-btn close"
title="Close pane"
onClick={(e) => {
e.stopPropagation();
orch.close(leaf.id);
}}
aria-label="Close pane"
>
×
</button>
</span>
</div>
<div className="xterm-wrap">
<XtermPane
distro={leaf.distro}
cwd={leaf.cwd}
onStatus={onStatus}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
focusTrigger={focusTrigger}
/>
</div>
</div>
);
}