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

372
src/App.tsx Normal file
View file

@ -0,0 +1,372 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
listDistros,
loadWorkspace,
saveWorkspace,
writeToPane,
killPane,
type PaneId,
} from "./ipc";
import {
type TreeNode,
type NodeId,
type Orientation,
type LeafNode,
newLeaf,
splitLeaf,
closeLeaf,
findLeaf,
leafCount,
walkLeaves,
changeDistro,
changeLabel,
toggleBroadcast as toggleBroadcastInTree,
serialize,
deserialize,
presetSingle,
presetTwoColumns,
presetThreeColumns,
presetTwoRows,
presetTwoByTwo,
} from "./lib/layout/tree";
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
import Pane from "./lib/layout/Pane";
import Notifications, { type Toast } from "./components/Notifications";
import Palette from "./components/Palette";
import "./App.css";
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
const SAVE_DEBOUNCE_MS = 500;
function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop");
}
export default function App() {
// ---- top-level state -----------------------------------------------------
const [tree, setTree] = useState<TreeNode>(() => newLeaf());
const [activeLeafId, setActiveLeafId] = useState<NodeId | null>(null);
const [distros, setDistros] = useState<string[]>([]);
const [defaultDistro, setDefaultDistro] = useState<string | undefined>(undefined);
const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
// ---- non-reactive lookups -----------------------------------------------
const paneIdByLeafRef = useRef<Map<NodeId, PaneId>>(new Map());
const nextNotifIdRef = useRef(1);
const treeRef = useRef(tree);
useEffect(() => {
treeRef.current = tree;
}, [tree]);
// ---- mount: load workspace + distros ------------------------------------
useEffect(() => {
let cancelled = false;
(async () => {
let loaded: TreeNode | null = null;
try {
const json = await loadWorkspace();
if (json) loaded = deserialize(json);
} catch (e) {
console.warn("loadWorkspace failed:", e);
}
if (!loaded) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
loaded = deserialize(legacy);
if (loaded) void saveWorkspace(legacy);
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
} catch (e) {
console.warn("legacy localStorage migration failed:", e);
}
}
let resolvedDistros: string[] = [];
let resolvedDefault: string | undefined;
try {
resolvedDistros = await listDistros();
resolvedDefault =
resolvedDistros.find(isInteractiveDistro) ?? resolvedDistros[0];
} catch (e) {
console.warn("list_distros failed:", e);
}
if (cancelled) return;
if (loaded) {
if (resolvedDefault) backfillDistro(loaded, resolvedDefault);
setTree(loaded);
} else if (resolvedDefault) {
setTree(newLeaf({ distro: resolvedDefault }));
}
setDistros(resolvedDistros);
setDefaultDistro(resolvedDefault);
setReady(true);
})();
return () => {
cancelled = true;
};
}, []);
// ---- debounced save ------------------------------------------------------
useEffect(() => {
if (!ready) return;
const id = window.setTimeout(() => {
saveWorkspace(serialize(tree)).catch((e) =>
console.warn("saveWorkspace failed:", e),
);
}, SAVE_DEBOUNCE_MS);
return () => clearTimeout(id);
}, [tree, ready]);
// ---- Ctrl+K palette toggle (capture phase to beat xterm) ----------------
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
e.stopPropagation();
setPaletteOpen((v) => !v);
}
}
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, []);
// ---- focus polling → setActive (xterm.js eats pointerdown) --------------
useEffect(() => {
let lastLeafId: string | null = null;
const interval = window.setInterval(() => {
const el = document.activeElement;
const leafEl = el?.closest("[data-leaf-id]");
const id = leafEl?.getAttribute("data-leaf-id") ?? null;
if (id && id !== lastLeafId) {
lastLeafId = id;
setActiveLeafId(id);
}
}, 250);
return () => clearInterval(interval);
}, []);
// ---- orchestration callbacks --------------------------------------------
const split = useCallback(
(leafId: NodeId, orientation: Orientation) => {
setTree((t) => {
const parent = findLeaf(t, leafId);
const inherit = parent
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
: { distro: defaultDistro };
return splitLeaf(t, leafId, orientation, inherit);
});
},
[defaultDistro],
);
const close = useCallback(
(leafId: NodeId) => {
const paneId = paneIdByLeafRef.current.get(leafId);
if (paneId != null) {
void killPane(paneId).catch((e) => console.warn("killPane failed:", e));
paneIdByLeafRef.current.delete(leafId);
}
setTree((t) => closeLeaf(t, leafId) ?? newLeaf({ distro: defaultDistro }));
setActiveLeafId((cur) => (cur === leafId ? null : cur));
},
[defaultDistro],
);
const setDistro = useCallback((leafId: NodeId, distro: string) => {
setTree((t) => changeDistro(t, leafId, distro));
}, []);
const setLabel = useCallback((leafId: NodeId, label: string | undefined) => {
setTree((t) => changeLabel(t, leafId, label));
}, []);
const toggleBroadcast = useCallback((leafId: NodeId) => {
setTree((t) => toggleBroadcastInTree(t, leafId));
}, []);
const setActive = useCallback((leafId: NodeId) => {
setActiveLeafId(leafId);
}, []);
const registerPaneId = useCallback(
(leafId: NodeId, paneId: PaneId | null) => {
if (paneId == null) paneIdByLeafRef.current.delete(leafId);
else paneIdByLeafRef.current.set(leafId, paneId);
},
[],
);
const broadcastFrom = useCallback(
(originLeafId: NodeId, dataB64: string) => {
let peers = 0;
for (const leaf of walkLeaves(treeRef.current)) {
if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue;
const paneId = paneIdByLeafRef.current.get(leaf.id);
if (paneId == null) continue;
peers++;
writeToPane(paneId, dataB64).catch((e) =>
console.warn("broadcast write failed:", e),
);
}
if (peers > 0) {
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
}
},
[],
);
const notify = useCallback((message: string) => {
const id = nextNotifIdRef.current++;
setNotifications((ns) => [...ns, { id, message }]);
window.setTimeout(() => {
setNotifications((ns) => ns.filter((n) => n.id !== id));
}, 5000);
}, []);
const dismissNotification = useCallback((id: number) => {
setNotifications((ns) => ns.filter((n) => n.id !== id));
}, []);
const orch = useMemo<Orchestration>(
() => ({
activeLeafId,
distros,
split,
close,
setDistro,
setLabel,
toggleBroadcast,
setActive,
registerPaneId,
broadcastFrom,
notify,
}),
[
activeLeafId,
distros,
split,
close,
setDistro,
setLabel,
toggleBroadcast,
setActive,
registerPaneId,
broadcastFrom,
notify,
],
);
const applyPreset = useCallback(
(make: (d: { distro?: string }) => TreeNode) => {
const count = leafCount(tree);
if (
count > 1 &&
!window.confirm(
`Replace current layout (${count} panes)? This kills all open shells.`,
)
) {
return;
}
setTree(make({ distro: defaultDistro }));
},
[tree, defaultDistro],
);
const paletteLeaves = useMemo<LeafNode[]>(
() => (paletteOpen ? Array.from(walkLeaves(tree)) : []),
[paletteOpen, tree],
);
const onPalettePick = useCallback((leafId: string) => {
setActiveLeafId(leafId);
setPaletteOpen(false);
}, []);
return (
<div className="app">
<header className="titlebar">
<span className="label">tiletopia</span>
<span className="distros">
{distros.length === 0 ? (
<span className="muted">no distros enumerated</span>
) : (
<>
<span className="muted">default:</span>
{distros.map((d) => (
<button
key={d}
className={`distro-btn${d === defaultDistro ? " active" : ""}`}
onClick={() => setDefaultDistro(d)}
title="Set default distro for new panes"
>
{d}
</button>
))}
</>
)}
</span>
<span className="presets">
<span className="muted">layout:</span>
<button className="preset-btn" title="Single pane" onClick={() => applyPreset(presetSingle)}>1</button>
<button className="preset-btn" title="Two columns" onClick={() => applyPreset(presetTwoColumns)}>2H</button>
<button className="preset-btn" title="Three columns" onClick={() => applyPreset(presetThreeColumns)}>3H</button>
<button className="preset-btn" title="Two rows" onClick={() => applyPreset(presetTwoRows)}>2V</button>
<button className="preset-btn" title="2 × 2 grid" onClick={() => applyPreset(presetTwoByTwo)}>2×2</button>
</span>
<button
className="palette-btn"
onClick={() => setPaletteOpen(true)}
title="Jump to pane (Ctrl+K)"
>
K
</button>
<button
className="palette-btn"
onClick={() => notify("test toast at " + new Date().toLocaleTimeString())}
title="Fire a test toast"
>
🔔
</button>
<span className="layout-info">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
</span>
</header>
<div className="pane-wrap">
{ready && (
<OrchestrationProvider value={orch}>
<Pane node={tree} />
</OrchestrationProvider>
)}
</div>
<Notifications notifications={notifications} onDismiss={dismissNotification} />
{paletteOpen && (
<Palette
leaves={paletteLeaves}
onPick={onPalettePick}
onClose={() => setPaletteOpen(false)}
/>
)}
</div>
);
}
function backfillDistro(node: TreeNode, fallback: string) {
if (node.kind === "leaf") {
if (!node.distro) node.distro = fallback;
} else {
backfillDistro(node.a, fallback);
backfillDistro(node.b, fallback);
}
}