import { useEffect, useRef, useState } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' import { wsURL } from './api' import { Spinner } from './ui' export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void } interface Props { id: string; active: boolean register: (h: TermHandle | null) => void onStatus: (ok: boolean) => void onExit: () => void } import { currentThemeId, themeById } from './themes' export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) { const box = useRef(null) const activeRef = useRef(active) activeRef.current = active const cbs = useRef({ onStatus, onExit }) cbs.current = { onStatus, onExit } const fitRef = useRef<() => void>(() => {}) const [conn, setConn] = useState<'connecting' | 'open' | 'reconnecting' | 'exited'>('connecting') useEffect(() => { const el = box.current! const term = new Terminal({ theme: themeById(currentThemeId()).xterm, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14, cursorBlink: true, scrollback: 5000, allowProposedApi: true, }) const fit = new FitAddon() term.loadAddon(fit) term.loadAddon(new WebLinksAddon()) term.open(el) let ws: WebSocket | null = null let closed = false let ctrlArmed = false let retry = 0 let retryTimer: number | undefined let lastSize = '' const enc = new TextEncoder() const queued: string[] = [] const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)); else queued.push(s) } const sendResize = () => { const key = `${term.cols}x${term.rows}` if (key === lastSize) return lastSize = key if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) } const doFit = () => { if (!activeRef.current || el.clientWidth === 0 || el.clientHeight === 0) return fit.fit() sendResize() } const connect = () => { if (closed) return const sock = new WebSocket(wsURL(id)) sock.binaryType = 'arraybuffer' ws = sock sock.onopen = () => { retry = 0; setConn('open'); cbs.current.onStatus(true); lastSize = ''; doFit(); while (queued.length) sock.send(enc.encode(queued.shift()!)) } sock.onmessage = (ev) => { if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return } try { const m = JSON.parse(ev.data) if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; setConn('exited'); cbs.current.onExit() } if (m.type === 'closed') closed = true } catch { /* ignore */ } } sock.onclose = () => { if (ws !== sock || closed) return cbs.current.onStatus(false) setConn('reconnecting') retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)) } sock.onerror = () => sock.close() } connect() term.onData((d) => { if (ctrlArmed && d.length === 1) { ctrlArmed = false const c = d.toUpperCase().charCodeAt(0) if (c >= 64 && c <= 95) { send(String.fromCharCode(c & 0x1f)); return } } send(d) }) term.onBinary((d) => send(d)) term.onResize(sendResize) fitRef.current = () => { doFit(); term.focus() } const ro = new ResizeObserver(() => doFit()) ro.observe(el) const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } } document.addEventListener('visibilitychange', onVis) const onTheme = (e: Event) => { term.options.theme = (e as CustomEvent).detail.xterm } window.addEventListener('hamlab:theme', onTheme) register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } }) return () => { register(null) closed = true clearTimeout(retryTimer) document.removeEventListener('visibilitychange', onVis) window.removeEventListener('hamlab:theme', onTheme) ro.disconnect() ws?.close() term.dispose() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) // Refit + focus whenever this pane becomes the visible one (after the display change applies). useEffect(() => { if (!active) return const t = window.setTimeout(() => fitRef.current(), 0) return () => clearTimeout(t) }, [active]) return (
{(conn === 'connecting' || conn === 'reconnecting') && (
{conn === 'connecting' ? 'connecting…' : 'reconnecting…'}
)}
) }