import { useCallback, useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebglAddon } from "@xterm/addon-webgl"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; import { UrlDetector } from "../../lib/urlDetector"; import UrlToast from "./UrlToast"; interface Props { sessionId: string; active: boolean; } export default function TerminalView({ sessionId, active }: Props) { const containerRef = useRef(null); const terminalContainerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const webglRef = useRef(null); const detectorRef = useRef(null); const { sendInput, resize, onOutput, onExit } = useTerminal(); const [detectedUrl, setDetectedUrl] = useState(null); useEffect(() => { if (!containerRef.current) return; const term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", theme: { background: "#0d1117", foreground: "#e6edf3", cursor: "#58a6ff", selectionBackground: "#264f78", black: "#484f58", red: "#ff7b72", green: "#3fb950", yellow: "#d29922", blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39d353", white: "#b1bac4", brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364", brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff", brightCyan: "#56d364", brightWhite: "#f0f6fc", }, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); // Web links addon — opens URLs in host browser via Tauri, with a permissive regex // that matches URLs even if they lack trailing path segments (the default regex // misses OAuth URLs that end mid-line). const urlRegex = /https?:\/\/[^\s'"\x07]+/; const webLinksAddon = new WebLinksAddon((_event, uri) => { openUrl(uri).catch((e) => console.error("Failed to open URL:", e)); }, { urlRegex }); term.loadAddon(webLinksAddon); term.open(containerRef.current); // WebGL addon is loaded/disposed dynamically in the active effect // to avoid exhausting the browser's limited WebGL context pool. fitAddon.fit(); termRef.current = term; fitRef.current = fitAddon; // Send initial size resize(sessionId, term.cols, term.rows); // Handle user input -> backend const inputDisposable = term.onData((data) => { sendInput(sessionId, data); }); // Handle backend output -> terminal let aborted = false; const detector = new UrlDetector((url) => setDetectedUrl(url)); detectorRef.current = detector; const outputPromise = onOutput(sessionId, (data) => { if (aborted) return; term.write(data); detector.feed(data); }).then((unlisten) => { if (aborted) unlisten(); return unlisten; }); const exitPromise = onExit(sessionId, () => { if (aborted) return; term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n"); }).then((unlisten) => { if (aborted) unlisten(); return unlisten; }); // Handle resize (throttled via requestAnimationFrame to avoid excessive calls). // Skip resize work for hidden terminals — containerRef will have 0 dimensions. let resizeRafId: number | null = null; const resizeObserver = new ResizeObserver(() => { if (resizeRafId !== null) return; const el = containerRef.current; if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return; resizeRafId = requestAnimationFrame(() => { resizeRafId = null; if (!containerRef.current || containerRef.current.offsetWidth === 0) return; fitAddon.fit(); resize(sessionId, term.cols, term.rows); }); }); resizeObserver.observe(containerRef.current); return () => { aborted = true; detector.dispose(); detectorRef.current = null; inputDisposable.dispose(); outputPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.()); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); resizeObserver.disconnect(); try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } webglRef.current = null; term.dispose(); }; }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps // Manage WebGL lifecycle and re-fit when tab becomes active. // Only the active terminal holds a WebGL context to avoid exhausting // the browser's limited pool (~8-16 contexts). useEffect(() => { const term = termRef.current; if (!term) return; if (active) { // Attach WebGL renderer if (!webglRef.current) { try { const addon = new WebglAddon(); addon.onContextLoss(() => { try { addon.dispose(); } catch { /* ignore */ } webglRef.current = null; }); term.loadAddon(addon); webglRef.current = addon; } catch { // WebGL not available, canvas renderer is fine } } fitRef.current?.fit(); term.focus(); } else { // Release WebGL context for inactive terminals if (webglRef.current) { try { webglRef.current.dispose(); } catch { /* ignore */ } webglRef.current = null; } } }, [active]); // Auto-dismiss toast after 30 seconds useEffect(() => { if (!detectedUrl) return; const timer = setTimeout(() => setDetectedUrl(null), 30_000); return () => clearTimeout(timer); }, [detectedUrl]); const handleOpenUrl = useCallback(() => { if (detectedUrl) { openUrl(detectedUrl).catch((e) => console.error("Failed to open URL:", e), ); setDetectedUrl(null); } }, [detectedUrl]); return (
{detectedUrl && ( setDetectedUrl(null)} /> )}
); }