import { useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import type { TerminalCard } from "../types"; import { useViewport } from "../viewport"; import { useDragHandle } from "../useDragHandle"; import { ResizeHandles } from "./ResizeHandles"; import { pty, base64ToBytes, stringToBase64 } from "../../ipc"; const BASE_FONT_SIZE = 13; interface Props { card: TerminalCard; onUpdate: (patch: Partial) => void; } export function TerminalCardView({ card, onUpdate }: Props) { const vp = useViewport(); const drag = useDragHandle(card, (p) => onUpdate(p)); const containerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const ptyIdRef = useRef(card.ptyId); const [ready, setReady] = useState(false); useEffect(() => { let unlistenData: (() => void) | undefined; let unlistenExit: (() => void) | undefined; let disposed = false; const term = new Terminal({ fontSize: BASE_FONT_SIZE * vp.scale, fontFamily: 'Menlo, "Cascadia Code", Consolas, monospace', theme: { background: "#1d1d23", foreground: "#e8e8ec", cursor: "#e8e8ec", }, cursorBlink: true, allowProposedApi: true, }); const fit = new FitAddon(); term.loadAddon(fit); term.open(containerRef.current!); fit.fit(); termRef.current = term; fitRef.current = fit; (async () => { let id = ptyIdRef.current; if (!id) { id = await pty.spawn({ cols: term.cols, rows: term.rows }); if (disposed) { await pty.kill(id); return; } ptyIdRef.current = id; onUpdate({ ptyId: id }); } unlistenData = await pty.onData((e) => { if (e.id !== id) return; term.write(base64ToBytes(e.data)); }); unlistenExit = await pty.onExit((e) => { if (e.id !== id) return; term.write("\r\n\x1b[2m[process exited]\x1b[0m\r\n"); }); term.onData((data) => { pty.write(id!, stringToBase64(data)).catch((err) => console.error("pty_write", err)); }); term.onResize(({ cols, rows }) => { pty.resize(id!, cols, rows).catch(() => {}); }); setReady(true); })(); return () => { disposed = true; unlistenData?.(); unlistenExit?.(); term.dispose(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Update xterm font size when canvas zoom changes so text stays crisp. useEffect(() => { if (!ready) return; const term = termRef.current; const fit = fitRef.current; if (!term || !fit) return; term.options.fontSize = BASE_FONT_SIZE * vp.scale; fit.fit(); }, [vp.scale, ready]); // Refit on card resize. useEffect(() => { if (!ready) return; fitRef.current?.fit(); }, [card.width, card.height, ready]); return (
terminal
e.stopPropagation()} onWheel={(e) => e.stopPropagation()} /> onUpdate(p as Partial)} />
); }