import { useEffect, useRef, useState } from "react"; import { Terminal as XTerm, type ITheme } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import "@xterm/xterm/css/xterm.css"; import { del, get, post } from "../api"; import { useT } from "../i18n"; import { navigate } from "../router"; import { onThemeChange } from "../theme"; import { toast } from "../dialogs"; import { Icon } from "./Icon"; export interface TermInfo { id: string; cwd: string; session_id?: string | null; created_at: number; alive: boolean; attached: boolean; } /** Where the next new terminal opens (set by "open terminal here" buttons). */ export const TERM_CWD = "codecraft.termCwd"; /** xterm colours from the active theme's CSS variables. */ function themeColors(): ITheme { const css = getComputedStyle(document.documentElement); const v = (name: string) => css.getPropertyValue(name).trim(); return { background: v("--bg0"), foreground: v("--fg1"), cursor: v("--accent"), cursorAccent: v("--bg0"), selectionBackground: `${v("--blue")}55`, black: v("--bg2"), red: v("--red"), green: v("--green"), yellow: v("--accent"), blue: v("--blue"), magenta: v("--purple"), cyan: v("--aqua"), white: v("--fg2"), brightBlack: v("--muted"), brightRed: v("--red"), brightGreen: v("--green"), brightYellow: v("--accent2"), brightBlue: v("--blue"), brightMagenta: v("--purple"), brightCyan: v("--aqua"), brightWhite: v("--fg0"), }; } const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/"; export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }) { const t = useT(); const [list, setList] = useState([]); const [error, setError] = useState(); const reload = () => get("/terminals") .then(setList) .catch((e) => setError(e.message)); useEffect(() => { void reload(); }, [id]); const open = async () => { try { const cwd = sessionStorage.getItem(TERM_CWD) ?? localStorage.getItem("codecraft.lastDir") ?? undefined; sessionStorage.removeItem(TERM_CWD); const info = await post("/terminals", { cwd, cols: 100, rows: 30 }); await reload(); navigate(`/terminal/${info.id}`); } catch (e: any) { toast(e.message); } }; // /terminal/new opens a fresh one; /terminal shows the newest. useEffect(() => { if (id === "new") void open(); else if (!id && list.length) navigate(`/terminal/${list[list.length - 1].id}`); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, list.length]); const close = async (tid: string) => { await del(`/terminals/${tid}`).catch(() => undefined); const rest = list.filter((x) => x.id !== tid); setList(rest); navigate(rest.length ? `/terminal/${rest[rest.length - 1].id}` : "/terminal"); }; const active = list.find((x) => x.id === id); return ( <>
{list.map((x) => ( ))}
{error &&
{error}
} {active ? ( ) : (
{list.length === 0 && id !== "new" && ( )}
)} ); } export function TermView({ info }: { info: TermInfo }) { const t = useT(); const host = useRef(null); const wsRef = useRef(null); const termRef = useRef(null); const [ctrl, setCtrl] = useState(false); const ctrlRef = useRef(false); const [state, setState] = useState<"connecting" | "open" | "closed">("connecting"); useEffect(() => { const term = new XTerm({ fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--mono").trim() || "monospace", fontSize: matchMedia("(max-width: 700px)").matches ? 12 : 14, cursorBlink: true, scrollback: 5000, theme: themeColors(), allowProposedApi: false, }); const fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon()); term.open(host.current!); termRef.current = term; const offTheme = onThemeChange(() => { term.options.theme = themeColors(); }); let ws: WebSocket; let stopped = false; let retry: ReturnType | undefined; const enc = new TextEncoder(); const sendResize = () => { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); }; const connect = () => { const proto = location.protocol === "https:" ? "wss" : "ws"; ws = new WebSocket(`${proto}://${location.host}/api/terminals/${info.id}/ws`); ws.binaryType = "arraybuffer"; wsRef.current = ws; let first = true; ws.onopen = () => { setState("open"); fit.fit(); sendResize(); }; ws.onmessage = (m) => { if (typeof m.data === "string") return; // hello / control if (first) { // Re-attached: the server replays recent output. term.reset(); first = false; } term.write(new Uint8Array(m.data)); }; ws.onclose = () => { setState("closed"); if (!stopped) retry = setTimeout(connect, 1500); }; }; connect(); term.onData((d) => { let data = d; // On-screen Ctrl toggle (mobile): turn the next letter into a control code. if (ctrlRef.current && d.length === 1) { const c = d.toUpperCase().charCodeAt(0); if (c >= 64 && c <= 95) data = String.fromCharCode(c - 64); ctrlRef.current = false; setCtrl(false); } if (ws.readyState === WebSocket.OPEN) ws.send(enc.encode(data)); }); const ro = new ResizeObserver(() => { try { fit.fit(); sendResize(); } catch { /* not visible */ } }); ro.observe(host.current!); term.focus(); return () => { stopped = true; clearTimeout(retry); ro.disconnect(); offTheme(); ws?.close(); term.dispose(); }; }, [info.id]); const key = (seq: string) => { const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(seq)); termRef.current?.focus(); }; return (
{state !== "open" &&
{t(state === "connecting" ? "terminal.connecting" : "terminal.reconnecting")}
}
); }