Files
platform/src/workspaces/apps/Terminal/Terminal.tsx
T
2026-02-19 18:01:08 +00:00

209 lines
6.0 KiB
TypeScript

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;
sessionId?: string;
sandboxed?: boolean;
cwd?: string;
fontSize?: number;
fontFamily?: string;
theme?: TerminalTheme;
autoFocus?: boolean;
onReady?: (term: XTerm) => void;
onExit?: () => void;
onDisconnect?: () => void;
};
const DEFAULT_THEME: Required<TerminalTheme> = {
background: '#1a1a2e',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
selectionBackground: '#3a3a5e',
};
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
if (sandboxed === false) url += '&sandboxed=false';
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
return url;
};
export const TerminalView = ({
className,
style,
wsPath = '/api/terminal/ws',
sessionId,
sandboxed = true,
cwd,
fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme,
autoFocus = true,
onReady,
onExit,
onDisconnect,
}: TerminalViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const isMounted = useMounted();
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(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);
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
if (viewport) {
viewport.style.scrollbarWidth = 'none';
viewport.style.overflow = 'hidden';
}
fitAddon.fit();
if (autoFocus) term.focus();
termRef.current = term;
fitAddonRef.current = fitAddon;
onReadyRef.current?.(term);
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd));
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?.();
} else if (msg.type === 'detached') {
term.write('\r\n[Session taken over]\r\n');
}
} 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,
sessionId,
sandboxed,
cwd,
fontSize,
fontFamily,
background,
foreground,
cursor,
selectionBackground,
autoFocus,
]);
return (
<div className={className} style={{ backgroundColor: background, overflow: 'hidden', ...style }}>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
</div>
);
};