import type { CSSProperties } from 'react'; import { useEffect, useRef } from 'react'; import { Terminal as XTerm } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; import { useMounted } from 'hooks/useMounted'; type TerminalTheme = { background?: string; foreground?: string; cursor?: string; selectionBackground?: string; }; export type TerminalViewProps = { className?: string; style?: CSSProperties; wsPath?: string; fontSize?: number; fontFamily?: string; theme?: TerminalTheme; autoFocus?: boolean; onReady?: (term: XTerm) => void; onExit?: () => void; onDisconnect?: () => void; }; const DEFAULT_THEME: Required = { background: '#1a1a2e', foreground: '#e0e0e0', cursor: '#e0e0e0', selectionBackground: '#3a3a5e', }; const buildWsUrl = (wsPath: string) => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = localStorage.getItem('BEARER_TOKEN') ?? ''; const separator = wsPath.includes('?') ? '&' : '?'; return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`; }; export const TerminalView = ({ className, style, wsPath = '/api/terminal/ws', fontSize = 14, fontFamily = 'Menlo, Monaco, "Courier New", monospace', theme, autoFocus = true, onReady, onExit, onDisconnect, }: TerminalViewProps) => { const containerRef = useRef(null); const termRef = useRef(null); const wsRef = useRef(null); const fitAddonRef = useRef(null); const isMounted = useMounted(); const onReadyRef = useRef(onReady); const onExitRef = useRef(onExit); const onDisconnectRef = useRef(onDisconnect); onReadyRef.current = onReady; onExitRef.current = onExit; onDisconnectRef.current = onDisconnect; const background = theme?.background ?? DEFAULT_THEME.background; const foreground = theme?.foreground ?? DEFAULT_THEME.foreground; const cursor = theme?.cursor ?? DEFAULT_THEME.cursor; const selectionBackground = theme?.selectionBackground ?? DEFAULT_THEME.selectionBackground; useEffect(() => { if (!isMounted) return; const container = containerRef.current; if (!container) return; let disposed = false; const initTimeout = setTimeout(() => { if (disposed) return; const term = new XTerm({ cursorBlink: true, fontSize, fontFamily, theme: { background, foreground, cursor, selectionBackground, }, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(container); fitAddon.fit(); if (autoFocus) term.focus(); termRef.current = term; fitAddonRef.current = fitAddon; onReadyRef.current?.(term); const ws = new WebSocket(buildWsUrl(wsPath)); wsRef.current = ws; const handleOpen = () => { ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); }; const handleMessage = (ev: MessageEvent) => { try { const msg = JSON.parse(ev.data as string); if (msg.type === 'output') { term.write(msg.data); } else if (msg.type === 'exit') { term.write('\r\n[Process exited]\r\n'); onExitRef.current?.(); } } catch { // ignore } }; const handleClose = () => { term.write('\r\n[Disconnected]\r\n'); onDisconnectRef.current?.(); }; ws.addEventListener('open', handleOpen); ws.addEventListener('message', handleMessage); ws.addEventListener('close', handleClose); const dataDisposable = term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data })); } }); const resizeObserver = new ResizeObserver(() => { fitAddon.fit(); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); } }); resizeObserver.observe(container); const cleanup = () => { dataDisposable.dispose(); resizeObserver.disconnect(); ws.removeEventListener('open', handleOpen); ws.removeEventListener('message', handleMessage); ws.removeEventListener('close', handleClose); }; (container as any).__terminalCleanup = cleanup; }, 0); return () => { disposed = true; clearTimeout(initTimeout); const cleanup = (container as any).__terminalCleanup as (() => void) | undefined; cleanup?.(); delete (container as any).__terminalCleanup; wsRef.current?.close(); wsRef.current = null; termRef.current?.dispose(); termRef.current = null; fitAddonRef.current = null; }; }, [ isMounted, wsPath, fontSize, fontFamily, background, foreground, cursor, selectionBackground, autoFocus, ]); return (
); };