189 lines
5.2 KiB
TypeScript
189 lines
5.2 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;
|
|
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) => {
|
|
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<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);
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={className}
|
|
style={{ backgroundColor: background, ...style }}
|
|
/>
|
|
);
|
|
};
|