| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useEffect, useRef } from 'react' |
| import useAppStore from '../store/appStore' |
| import { Terminal } from '@xterm/xterm' |
| import { FitAddon } from '@xterm/addon-fit' |
| import '@xterm/xterm/css/xterm.css' |
|
|
| export default function TerminalPanel() { |
| const projectId = useAppStore((s) => s.project.id) |
| const theme = useAppStore((s) => s.theme) |
| const hostRef = useRef(null) |
| const termRef = useRef(null) |
| const wsRef = useRef(null) |
| const fitRef = useRef(null) |
|
|
| useEffect(() => { |
| if (!projectId || !hostRef.current) return |
|
|
| |
| |
| const dark = theme !== 'light' |
| const term = new Terminal({ |
| fontFamily: 'ui-monospace, "JetBrains Mono", Menlo, monospace', |
| fontSize: 12.5, |
| lineHeight: 1.15, |
| cursorBlink: true, |
| convertEol: false, |
| scrollback: 5000, |
| allowProposedApi: true, |
| theme: dark |
| ? { background: '#0b1220', foreground: '#e2e8f0', cursor: '#22d3ee', selectionBackground: '#1e3a8a' } |
| : { background: '#f8fafc', foreground: '#0f172a', cursor: '#0284c7', selectionBackground: '#bfdbfe' }, |
| }) |
| const fit = new FitAddon() |
| term.loadAddon(fit) |
| term.open(hostRef.current) |
| fit.fit() |
| term.focus() |
| termRef.current = term |
| fitRef.current = fit |
|
|
| const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' |
| const wsUrl = `${proto}//${window.location.host}` |
| + `/api/projects/${projectId}/terminal` |
| + `?cols=${term.cols}&rows=${term.rows}` |
| const ws = new WebSocket(wsUrl) |
| ws.binaryType = 'arraybuffer' |
| wsRef.current = ws |
|
|
| ws.onopen = () => { |
| term.write('\x1b[2m[tesseract] terminal connected — bash/zsh at project root.\x1b[0m\r\n') |
| } |
| ws.onmessage = (ev) => { |
| if (ev.data instanceof ArrayBuffer) { |
| term.write(new Uint8Array(ev.data)) |
| } else if (typeof ev.data === 'string') { |
| term.write(ev.data) |
| } |
| } |
| ws.onclose = () => { |
| term.write('\r\n\x1b[2m[tesseract] terminal disconnected.\x1b[0m\r\n') |
| } |
| ws.onerror = () => { |
| term.write('\r\n\x1b[31m[tesseract] terminal error.\x1b[0m\r\n') |
| } |
|
|
| const onData = term.onData((data) => { |
| if (ws.readyState === WebSocket.OPEN) ws.send(data) |
| }) |
|
|
| const ro = new ResizeObserver(() => { |
| try { |
| fit.fit() |
| if (ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ resize: [term.cols, term.rows] })) |
| } |
| } catch { } |
| }) |
| ro.observe(hostRef.current) |
|
|
| return () => { |
| ro.disconnect() |
| onData.dispose() |
| try { ws.close() } catch { } |
| term.dispose() |
| termRef.current = null |
| fitRef.current = null |
| wsRef.current = null |
| } |
| }, [projectId, theme]) |
|
|
| if (!projectId) { |
| return ( |
| <div style={{ height: '100%', display: 'grid', placeItems: 'center', color: 'var(--text-faint)', fontSize: 12 }}> |
| Open a project to launch a terminal. |
| </div> |
| ) |
| } |
| return ( |
| <div |
| ref={hostRef} |
| style={{ |
| width: '100%', |
| height: '100%', |
| background: theme === 'light' ? '#f8fafc' : '#0b1220', |
| padding: 6, |
| boxSizing: 'border-box', |
| }} |
| /> |
| ) |
| } |
|
|