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