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
372
src/App.tsx
Normal file
372
src/App.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue