refactor(web): extract reusable XtermSession from WorkspaceTerminal
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import type { ITheme, Terminal } from '@xterm/xterm';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import { scheduleTerminalFits } from './terminal-fit';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const TERMINAL_FONT =
|
||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
|
||||
|
||||
export type XtermStatus =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'closed'
|
||||
| 'error'
|
||||
| 'unconfigured';
|
||||
|
||||
const darkTheme: ITheme = {
|
||||
background: '#080e14',
|
||||
foreground: '#eef3f5',
|
||||
cursor: '#1fb895',
|
||||
cursorAccent: '#080e14',
|
||||
selectionBackground: '#1fb89544',
|
||||
black: '#10171e',
|
||||
red: '#f3625d',
|
||||
green: '#8fd6b4',
|
||||
yellow: '#e3b341',
|
||||
blue: '#6aa6ff',
|
||||
magenta: '#b692e8',
|
||||
cyan: '#5fd0e0',
|
||||
white: '#cdd6dc',
|
||||
brightBlack: '#93a1a9',
|
||||
brightRed: '#f3625d',
|
||||
brightGreen: '#8fd6b4',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#6aa6ff',
|
||||
brightMagenta: '#b692e8',
|
||||
brightCyan: '#5fd0e0',
|
||||
brightWhite: '#eef3f5',
|
||||
};
|
||||
|
||||
const lightTheme: ITheme = {
|
||||
background: '#f7fbfa',
|
||||
foreground: '#0d1218',
|
||||
cursor: '#007560',
|
||||
cursorAccent: '#f7fbfa',
|
||||
selectionBackground: '#00756033',
|
||||
black: '#0d1218',
|
||||
red: '#d73337',
|
||||
green: '#2f8f6e',
|
||||
yellow: '#9a6b00',
|
||||
blue: '#2f6bd8',
|
||||
magenta: '#7c4dd1',
|
||||
cyan: '#0f7d92',
|
||||
white: '#26323c',
|
||||
brightBlack: '#555f68',
|
||||
brightRed: '#d73337',
|
||||
brightGreen: '#2f8f6e',
|
||||
brightYellow: '#9a6b00',
|
||||
brightBlue: '#2f6bd8',
|
||||
brightMagenta: '#7c4dd1',
|
||||
brightCyan: '#0f7d92',
|
||||
brightWhite: '#0d1218',
|
||||
};
|
||||
|
||||
export const XtermSession = ({
|
||||
active,
|
||||
tokenUrl,
|
||||
sessionKey,
|
||||
waitingLabel,
|
||||
copyOnSelect,
|
||||
}: {
|
||||
active: boolean;
|
||||
tokenUrl: string;
|
||||
sessionKey: string;
|
||||
waitingLabel?: string;
|
||||
copyOnSelect?: boolean;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const themeIsLight = resolvedTheme === 'light';
|
||||
const [status, setStatus] = useState<XtermStatus>('connecting');
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
const [reconnectKey, setReconnectKey] = useState(0);
|
||||
|
||||
// Update the live terminal's theme without tearing down the session.
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
|
||||
}
|
||||
}, [themeIsLight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
// Read through a function so TS doesn't narrow `aborted` to a constant after
|
||||
// the first guard (it changes asynchronously, on cleanup).
|
||||
const isAborted = () => signal.aborted;
|
||||
let ws: WebSocket | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const start = async () => {
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
|
||||
[
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
import('@xterm/addon-web-links'),
|
||||
],
|
||||
);
|
||||
if (isAborted()) return;
|
||||
|
||||
setStatus('connecting');
|
||||
setErrorText(undefined);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(tokenUrl, { signal });
|
||||
} catch {
|
||||
if (!isAborted()) setStatus('error');
|
||||
return;
|
||||
}
|
||||
if (isAborted()) return;
|
||||
if (response.status === 503) {
|
||||
setStatus('unconfigured');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus('error');
|
||||
setErrorText(await response.text().catch(() => undefined));
|
||||
return;
|
||||
}
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: TERMINAL_FONT,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
theme: themeIsLight ? lightTheme : darkTheme,
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(container);
|
||||
termRef.current = term;
|
||||
|
||||
// Task 11 will wire clipboard copy-on-select behavior here. Accept the
|
||||
// prop now so callers (and the effect deps) are stable across that change.
|
||||
if (copyOnSelect) {
|
||||
term.onSelectionChange(() => {
|
||||
// no-op placeholder until Task 11
|
||||
});
|
||||
}
|
||||
|
||||
const sendResize = () => {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
|
||||
);
|
||||
};
|
||||
|
||||
scheduleTerminalFits({
|
||||
fit: () => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore transient layout errors
|
||||
}
|
||||
},
|
||||
sendResize,
|
||||
fontsReady: document.fonts.ready,
|
||||
loadNerdFont: () =>
|
||||
document.fonts
|
||||
.load("16px 'Symbols Nerd Font Mono'", '')
|
||||
.then(() => {
|
||||
if (!isAborted()) term.refresh(0, term.rows - 1);
|
||||
}),
|
||||
raf: (cb) => requestAnimationFrame(cb),
|
||||
isAborted,
|
||||
});
|
||||
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
if (isAborted()) return;
|
||||
setStatus('connected');
|
||||
sendResize();
|
||||
term.focus();
|
||||
};
|
||||
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
|
||||
if (typeof event.data === 'string') term.write(event.data);
|
||||
else term.write(new Uint8Array(event.data));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!isAborted()) setStatus('closed');
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (!isAborted()) setStatus('error');
|
||||
};
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
|
||||
});
|
||||
term.onResize(() => sendResize());
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// ignore transient layout errors
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
resizeObserver?.disconnect();
|
||||
ws?.close();
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, sessionKey, reconnectKey]);
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{status === 'connected'
|
||||
? 'Connected · workspace shell'
|
||||
: status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: status === 'closed'
|
||||
? 'Session ended'
|
||||
: status === 'unconfigured'
|
||||
? 'Terminal not configured'
|
||||
: 'Connection error'}
|
||||
</p>
|
||||
{status === 'closed' || status === 'error' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setReconnectKey((key) => key + 1)}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{status === 'unconfigured' ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||
The terminal is not enabled on this deployment.
|
||||
<br />
|
||||
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
|
||||
</div>
|
||||
) : !active && waitingLabel ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
|
||||
{waitingLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
|
||||
<div ref={containerRef} className='h-full w-full' />
|
||||
{errorText ? (
|
||||
<p className='text-destructive mt-2 px-1 text-xs break-all'>
|
||||
{errorText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user