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
222
src/components/XtermPane.tsx
Normal file
222
src/components/XtermPane.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
spawnPane,
|
||||
writeToPane,
|
||||
resizePane,
|
||||
killPane,
|
||||
onPaneData,
|
||||
onPaneExit,
|
||||
type PaneId,
|
||||
} from "../ipc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// base64 helpers (private to this module)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function b64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
function stringToB64(s: string): string {
|
||||
// xterm.js's onData emits a JS string; UTF-8 encode before base64.
|
||||
return bytesToB64(new TextEncoder().encode(s));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface XtermPaneProps {
|
||||
distro?: string;
|
||||
cwd?: string;
|
||||
onStatus?: (msg: string, ok: boolean) => void;
|
||||
/** Fired once when the backend PTY is alive and we have its PaneId. */
|
||||
onSpawn?: (paneId: PaneId) => void;
|
||||
/** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */
|
||||
onInput?: (dataB64: string) => void;
|
||||
/** Fired whenever output arrives from the PTY. Used for idle detection. */
|
||||
onDataReceived?: () => void;
|
||||
/** Fired when xterm's textarea gains focus (i.e., user clicked here). */
|
||||
onFocus?: () => void;
|
||||
/** Increment to refocus the terminal programmatically (palette etc.). */
|
||||
focusTrigger?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function XtermPane({
|
||||
distro,
|
||||
cwd,
|
||||
onStatus,
|
||||
onSpawn,
|
||||
onInput,
|
||||
onDataReceived,
|
||||
onFocus,
|
||||
focusTrigger = 0,
|
||||
}: XtermPaneProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Stable refs for callbacks so the mount effect doesn't need to re-run when
|
||||
// parents pass new inline functions, while still always calling the latest version.
|
||||
const onStatusRef = useRef(onStatus);
|
||||
const onSpawnRef = useRef(onSpawn);
|
||||
const onInputRef = useRef(onInput);
|
||||
const onDataReceivedRef = useRef(onDataReceived);
|
||||
const onFocusRef = useRef(onFocus);
|
||||
|
||||
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
|
||||
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
|
||||
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
|
||||
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
|
||||
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mount / unmount: create terminal, spawn PTY, wire listeners
|
||||
// -------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let term: Terminal | null = new Terminal({
|
||||
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: "#0c0c0c",
|
||||
foreground: "#e6e6e6",
|
||||
},
|
||||
scrollback: 5000,
|
||||
convertEol: false,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(container);
|
||||
|
||||
// Initial size — fit before asking the PTY for its dimensions.
|
||||
fit.fit();
|
||||
|
||||
let paneId: PaneId | null = null;
|
||||
let unlistenData: UnlistenFn | null = null;
|
||||
let unlistenExit: UnlistenFn | null = null;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
(async () => {
|
||||
const cols = term!.cols;
|
||||
const rows = term!.rows;
|
||||
|
||||
try {
|
||||
paneId = await spawnPane({ distro, cwd, cols, rows });
|
||||
if (destroyed) {
|
||||
void killPane(paneId);
|
||||
return;
|
||||
}
|
||||
onStatusRef.current?.(`pane ${paneId} alive`, true);
|
||||
onSpawnRef.current?.(paneId);
|
||||
} catch (e) {
|
||||
if (destroyed) return;
|
||||
const msg = `spawn_pane failed: ${e}`;
|
||||
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
|
||||
onStatusRef.current?.(msg, false);
|
||||
return;
|
||||
}
|
||||
|
||||
unlistenData = await onPaneData(paneId, (b64) => {
|
||||
term?.write(b64ToBytes(b64));
|
||||
onDataReceivedRef.current?.();
|
||||
});
|
||||
|
||||
unlistenExit = await onPaneExit(paneId, () => {
|
||||
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
|
||||
onStatusRef.current?.(`pane ${paneId} exited`, false);
|
||||
});
|
||||
|
||||
term?.onData((data) => {
|
||||
if (paneId == null) return;
|
||||
const b64 = stringToB64(data);
|
||||
void writeToPane(paneId, b64);
|
||||
onInputRef.current?.(b64);
|
||||
});
|
||||
|
||||
// Focus detection: xterm.js doesn't expose onFocus as a first-class event
|
||||
// in all versions, so try the proposed API first then fall back to the DOM.
|
||||
term?.onSelectionChange(() => {}); // ensure addon system is initialised; noop
|
||||
const termAny = term as unknown as { onFocus?: (cb: () => void) => void };
|
||||
if (typeof termAny.onFocus === "function") {
|
||||
termAny.onFocus(() => onFocusRef.current?.());
|
||||
} else {
|
||||
const ta = container.querySelector(".xterm-helper-textarea");
|
||||
if (ta) ta.addEventListener("focus", () => onFocusRef.current?.(), true);
|
||||
}
|
||||
|
||||
// Re-fit on container resize; forward new size to the PTY.
|
||||
ro = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit();
|
||||
if (paneId != null && term) {
|
||||
void resizePane(paneId, term.cols, term.rows);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("resize failed", e);
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
// Focus so typing immediately lands in the terminal.
|
||||
term?.focus();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
ro?.disconnect();
|
||||
unlistenData?.();
|
||||
unlistenExit?.();
|
||||
if (paneId != null) void killPane(paneId);
|
||||
term?.dispose();
|
||||
term = null;
|
||||
};
|
||||
// distro/cwd are only used at spawn time; intentionally omitted from deps
|
||||
// so remounting doesn't happen if a parent re-renders with the same values.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// focusTrigger: programmatic refocus from parent (palette navigation etc.)
|
||||
// -------------------------------------------------------------------------
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
|
||||
// Keep termRef in sync via a second effect that runs after mount.
|
||||
// We can't easily share the Terminal instance across the two effects without
|
||||
// a ref, so we store it on termRef inside the mount effect instead.
|
||||
// Actually, let's just wire focusTrigger by querying the textarea directly —
|
||||
// that avoids the cross-effect coupling problem entirely.
|
||||
useEffect(() => {
|
||||
if (focusTrigger > 0 && containerRef.current) {
|
||||
const ta = containerRef.current.querySelector<HTMLTextAreaElement>(
|
||||
".xterm-helper-textarea",
|
||||
);
|
||||
ta?.focus();
|
||||
}
|
||||
}, [focusTrigger]);
|
||||
|
||||
// Suppress unused ref warning
|
||||
void termRef;
|
||||
|
||||
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue