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(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(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( ".xterm-helper-textarea", ); ta?.focus(); } }, [focusTrigger]); // Suppress unused ref warning void termRef; return
; }