Wire copy-on-select in XtermSession (shared by the workspace terminal and the /machine terminal): a header checkbox toggles the behavior and persists to localStorage under spoon.terminal.copyOnSelect. On mount the stored value takes precedence over the copyOnSelect prop default. When enabled, onSelectionChange copies the current selection via navigator.clipboard.writeText — guarded (empty selections skipped; clipboard-unavailable/denied errors swallowed). The handler reads the toggle through a ref so live flips apply without reconnecting. WebLinksAddon remains loaded so printed URLs are clickable. Manual verification: in /machine and a workspace terminal, enabling copy-on-select and selecting text places it on the clipboard; a printed URL is clickable and opens in a new tab.
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
'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";
|
||
|
||
// Persisted user preference for copy-on-select. A stored value takes precedence
|
||
// over the `copyOnSelect` prop default so the toggle sticks across sessions.
|
||
const COPY_ON_SELECT_KEY = 'spoon.terminal.copyOnSelect';
|
||
|
||
const readStoredCopyOnSelect = (): boolean | undefined => {
|
||
try {
|
||
const stored = window.localStorage.getItem(COPY_ON_SELECT_KEY);
|
||
if (stored === 'true') return true;
|
||
if (stored === 'false') return false;
|
||
} catch {
|
||
// localStorage may be unavailable (private mode, SSR); fall back to prop.
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
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,
|
||
visible = true,
|
||
tokenUrl,
|
||
sessionKey,
|
||
waitingLabel,
|
||
copyOnSelect,
|
||
label = 'workspace shell',
|
||
}: {
|
||
active: boolean;
|
||
visible?: boolean;
|
||
tokenUrl: string;
|
||
sessionKey: string;
|
||
waitingLabel?: string;
|
||
copyOnSelect?: boolean;
|
||
label?: string;
|
||
}) => {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const termRef = useRef<Terminal | null>(null);
|
||
const fitRef = useRef<{ fit: () => void } | null>(null);
|
||
const wsRef = useRef<WebSocket | null>(null);
|
||
const { resolvedTheme } = useTheme();
|
||
const themeIsLight = resolvedTheme === 'light';
|
||
const [status, setStatus] = useState<XtermStatus>('idle');
|
||
const [errorText, setErrorText] = useState<string>();
|
||
const [reconnectKey, setReconnectKey] = useState(0);
|
||
// localStorage overrides the prop default; the prop is only the initial value
|
||
// when nothing has been persisted yet.
|
||
const [copyEnabled, setCopyEnabled] = useState<boolean>(
|
||
() => readStoredCopyOnSelect() ?? copyOnSelect ?? false,
|
||
);
|
||
// The connect effect registers onSelectionChange once; it reads the current
|
||
// toggle through this ref so live flips take effect without a reconnect.
|
||
const copyEnabledRef = useRef(copyEnabled);
|
||
useEffect(() => {
|
||
copyEnabledRef.current = copyEnabled;
|
||
}, [copyEnabled]);
|
||
|
||
const toggleCopyOnSelect = () => {
|
||
setCopyEnabled((prev) => {
|
||
const next = !prev;
|
||
try {
|
||
window.localStorage.setItem(COPY_ON_SELECT_KEY, String(next));
|
||
} catch {
|
||
// Persistence is best-effort; ignore storage failures.
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// 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;
|
||
fitRef.current = fit;
|
||
|
||
// Copy-on-select: when enabled, mirror the selection to the clipboard.
|
||
// Registered once; the ref lets live toggle flips apply without a
|
||
// reconnect. Clipboard access is guarded — it may be unavailable or
|
||
// denied, and empty selections are skipped.
|
||
term.onSelectionChange(() => {
|
||
if (!copyEnabledRef.current) return;
|
||
const selection = term.getSelection();
|
||
if (!selection) return;
|
||
try {
|
||
void navigator.clipboard.writeText(selection).catch(() => {
|
||
// clipboard write denied/unavailable; ignore
|
||
});
|
||
} catch {
|
||
// navigator.clipboard missing entirely; ignore
|
||
}
|
||
});
|
||
|
||
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);
|
||
wsRef.current = ws;
|
||
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;
|
||
fitRef.current = null;
|
||
wsRef.current = null;
|
||
};
|
||
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [active, sessionKey, reconnectKey]);
|
||
|
||
// A hidden (display:none) container measures 0×0, so xterm mis-sizes while the
|
||
// tab is inactive. When the terminal becomes visible again, refit inside a
|
||
// frame (so layout has settled) and push the new dimensions to the PTY. The
|
||
// WebSocket stays open across visibility changes — this never reconnects.
|
||
useEffect(() => {
|
||
if (!visible || !active) return;
|
||
const frame = requestAnimationFrame(() => {
|
||
try {
|
||
fitRef.current?.fit();
|
||
} catch {
|
||
// ignore transient layout errors
|
||
}
|
||
const ws = wsRef.current;
|
||
const term = termRef.current;
|
||
if (ws?.readyState === WebSocket.OPEN && term) {
|
||
ws.send(
|
||
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
|
||
);
|
||
}
|
||
});
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [visible, active]);
|
||
|
||
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 · ${label}`
|
||
: status === 'idle'
|
||
? (waitingLabel ? 'Waiting…' : 'Idle')
|
||
: status === 'connecting'
|
||
? 'Connecting…'
|
||
: status === 'closed'
|
||
? 'Session ended'
|
||
: status === 'unconfigured'
|
||
? 'Terminal not configured'
|
||
: 'Connection error'}
|
||
</p>
|
||
<div className='flex items-center gap-3'>
|
||
<label className='text-muted-foreground flex cursor-pointer items-center gap-1.5 text-xs select-none'>
|
||
<input
|
||
type='checkbox'
|
||
className='accent-primary size-3.5'
|
||
checked={copyEnabled}
|
||
onChange={toggleCopyOnSelect}
|
||
/>
|
||
Copy on select
|
||
</label>
|
||
{status === 'closed' || status === 'error' ? (
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
size='sm'
|
||
onClick={() => setReconnectKey((key) => key + 1)}
|
||
>
|
||
Reconnect
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</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>
|
||
);
|
||
};
|