import { useEffect, useRef, useCallback } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' import '@xterm/xterm/css/xterm.css' import styles from './Terminal.module.css' // ── xterm.js themes ────────────────────────────────────────────────────────── const TERM_THEMES = { dark: { background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff', cursorAccent: '#0d1117', selectionBackground:'#264f78', black: '#0d1117', brightBlack: '#6e7681', red: '#ff7b72', brightRed: '#ffa198', green: '#3fb950', brightGreen: '#56d364', yellow: '#d29922', brightYellow: '#e3b341', blue: '#58a6ff', brightBlue: '#79c0ff', magenta: '#bc8cff', brightMagenta: '#d2a8ff', cyan: '#39c5cf', brightCyan: '#56d4dd', white: '#e6edf3', brightWhite: '#ffffff', }, light: { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#f6f8fa', selectionBackground:'rgba(84,174,255,0.35)', black: '#24292f', brightBlack: '#57606a', red: '#cf222e', brightRed: '#a40e26', green: '#116329', brightGreen: '#1a7f37', yellow: '#633c01', brightYellow: '#7d4e00', blue: '#0969da', brightBlue: '#218bff', magenta: '#8250df', brightMagenta: '#a475f9', cyan: '#1b7c83', brightCyan: '#3192aa', white: '#6e7781', brightWhite: '#8c959f', }, } function getCurrentTheme() { return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark' } // ── Component ───────────────────────────────────────────────────────────────── export default function TerminalComponent({ collapsed, onToggleCollapse }) { const containerRef = useRef(null) const termRef = useRef(null) const fitRef = useRef(null) const wsRef = useRef(null) const fit = useCallback(() => { const fitAddon = fitRef.current const term = termRef.current if (!fitAddon || !term) return if (!containerRef.current || containerRef.current.offsetHeight === 0) return try { fitAddon.fit() if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) } } catch {} }, []) const connect = useCallback(() => { const term = termRef.current if (!term) return if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close() } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${proto}//${location.host}/shell-ws`) wsRef.current = ws ws.onopen = () => { term.clear(); fit() } ws.onmessage = (e) => { term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data)) } ws.onclose = () => { term.write('\r\n\x1b[33m[Disconnected — click Reconnect]\x1b[0m\r\n') } ws.onerror = () => { term.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n') } }, [fit]) // Re-fit one frame after expand so the CSS height transition has settled useEffect(() => { if (!collapsed) { const id = requestAnimationFrame(() => fit()) return () => cancelAnimationFrame(id) } }, [collapsed, fit]) // Mount terminal useEffect(() => { const term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: '"Cascadia Code", "Fira Code", Menlo, Monaco, "Courier New", monospace', theme: TERM_THEMES[getCurrentTheme()], scrollback: 5000, allowTransparency: false, }) const fitAddon = new FitAddon() term.loadAddon(fitAddon) term.loadAddon(new WebLinksAddon()) term.open(containerRef.current) requestAnimationFrame(() => fitAddon.fit()) termRef.current = term fitRef.current = fitAddon term.onData((data) => { if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data) }) connect() window.addEventListener('resize', fit) const ro = new ResizeObserver(() => fit()) if (containerRef.current) ro.observe(containerRef.current) // Watch and update xterm theme live const mo = new MutationObserver((mutations) => { for (const m of mutations) { if (m.attributeName === 'data-theme') { term.options.theme = TERM_THEMES[getCurrentTheme()] } } }) mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) return () => { window.removeEventListener('resize', fit) ro.disconnect() mo.disconnect() wsRef.current?.close() term.dispose() } }, [connect, fit]) return (
$_ bash — rootnode
{/* xtermOuter is position:relative so the absolutely-positioned xterm mount point (containerRef) fills it exactly — canonical xterm.js pattern. The div stays in the DOM even when collapsed so the PTY session lives. */}
) }