feat(web): integrate xterm.js with WebSocket terminal backend (#518)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #518.
This commit is contained in:
427
apps/web/src/components/terminal/XTerminal.tsx
Normal file
427
apps/web/src/components/terminal/XTerminal.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* XTerminal component
|
||||
*
|
||||
* Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace.
|
||||
* Handles resize, copy/paste, theme, and session lifecycle.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import type { ReactElement, CSSProperties } from "react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
|
||||
import { useTerminal } from "@/hooks/useTerminal";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export interface XTerminalProps {
|
||||
/** Authentication token for the WebSocket connection */
|
||||
token: string;
|
||||
/** Optional CSS class name for the outer container */
|
||||
className?: string;
|
||||
/** Optional inline styles for the outer container */
|
||||
style?: CSSProperties;
|
||||
/** Whether the terminal is visible (used to trigger re-fit) */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Theme helpers
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Read a CSS variable value from :root via computed styles.
|
||||
* Falls back to the provided default value if not available (e.g., during SSR).
|
||||
*/
|
||||
function getCssVar(varName: string, fallback: string): string {
|
||||
if (typeof document === "undefined") return fallback;
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
return value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an xterm.js ITheme object from the current design system CSS variables.
|
||||
* All 5 themes (dark, light, aurora, midnight, sunlit, ocean) update --ms-* primitives
|
||||
* which flow through to the semantic aliases we read here.
|
||||
*/
|
||||
function buildXtermTheme(): Record<string, string> {
|
||||
return {
|
||||
background: getCssVar("--bg-deep", "#080b12"),
|
||||
foreground: getCssVar("--text", "#eef3ff"),
|
||||
cursor: getCssVar("--success", "#14b8a6"),
|
||||
cursorAccent: getCssVar("--bg-deep", "#080b12"),
|
||||
selectionBackground: `${getCssVar("--primary", "#2f80ff")}40`,
|
||||
selectionForeground: getCssVar("--text", "#eef3ff"),
|
||||
selectionInactiveBackground: `${getCssVar("--muted", "#8f9db7")}30`,
|
||||
// Standard ANSI colors mapped to design system
|
||||
black: getCssVar("--bg-deep", "#080b12"),
|
||||
red: getCssVar("--danger", "#e5484d"),
|
||||
green: getCssVar("--success", "#14b8a6"),
|
||||
yellow: getCssVar("--warn", "#f59e0b"),
|
||||
blue: getCssVar("--primary", "#2f80ff"),
|
||||
magenta: getCssVar("--purple", "#8b5cf6"),
|
||||
cyan: "#06b6d4",
|
||||
white: getCssVar("--text-2", "#c5d0e6"),
|
||||
brightBlack: getCssVar("--muted", "#8f9db7"),
|
||||
brightRed: "#f06a6f",
|
||||
brightGreen: "#2dd4bf",
|
||||
brightYellow: "#fbbf24",
|
||||
brightBlue: "#56a0ff",
|
||||
brightMagenta: "#a78bfa",
|
||||
brightCyan: "#22d3ee",
|
||||
brightWhite: getCssVar("--text", "#eef3ff"),
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Component
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* XTerminal renders a real PTY terminal powered by xterm.js,
|
||||
* connected to the backend /terminal WebSocket namespace.
|
||||
*/
|
||||
export function XTerminal({
|
||||
token,
|
||||
className = "",
|
||||
style,
|
||||
isVisible = true,
|
||||
}: XTerminalProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<XFitAddon | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const isTerminalMountedRef = useRef(false);
|
||||
|
||||
const [hasExited, setHasExited] = useState(false);
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
|
||||
// ==========================================
|
||||
// Terminal session callbacks
|
||||
// ==========================================
|
||||
|
||||
const handleOutput = useCallback((sessionId: string, data: string): void => {
|
||||
void sessionId; // sessionId is single-session in this component
|
||||
terminalRef.current?.write(data);
|
||||
}, []);
|
||||
|
||||
const handleExit = useCallback((event: { sessionId: string; exitCode: number }): void => {
|
||||
void event.sessionId;
|
||||
setHasExited(true);
|
||||
setExitCode(event.exitCode);
|
||||
const term = terminalRef.current;
|
||||
if (term) {
|
||||
term.write(`\r\n\x1b[33m[Process exited with code ${event.exitCode.toString()}]\x1b[0m\r\n`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((message: string): void => {
|
||||
const term = terminalRef.current;
|
||||
if (term) {
|
||||
term.write(`\r\n\x1b[31m[Error: ${message}]\x1b[0m\r\n`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { isConnected, sessionId, createSession, sendInput, resize, closeSession } = useTerminal({
|
||||
token,
|
||||
onOutput: handleOutput,
|
||||
onExit: handleExit,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Fit helper
|
||||
// ==========================================
|
||||
|
||||
const fitAndResize = useCallback((): void => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
const terminal = terminalRef.current;
|
||||
if (!fitAddon || !terminal) return;
|
||||
|
||||
try {
|
||||
fitAddon.fit();
|
||||
resize(terminal.cols, terminal.rows);
|
||||
} catch {
|
||||
// Ignore fit errors (e.g., when container has zero dimensions)
|
||||
}
|
||||
}, [resize]);
|
||||
|
||||
// ==========================================
|
||||
// Mount xterm.js terminal (client-only)
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || isTerminalMountedRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const mountTerminal = async (): Promise<void> => {
|
||||
// Dynamic imports ensure DOM-dependent xterm.js modules are never loaded server-side
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-web-links"),
|
||||
]);
|
||||
|
||||
if (cancelled || !containerRef.current) return;
|
||||
|
||||
const theme = buildXtermTheme();
|
||||
|
||||
const terminal = new Terminal({
|
||||
fontFamily: "var(--mono, 'Fira Code', 'Cascadia Code', monospace)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
scrollback: 10000,
|
||||
theme,
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
// Accessibility
|
||||
screenReaderMode: false,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
terminal.open(containerRef.current);
|
||||
|
||||
terminalRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
isTerminalMountedRef.current = true;
|
||||
|
||||
// Initial fit
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
// Container might not have dimensions yet
|
||||
}
|
||||
|
||||
// Set up ResizeObserver for automatic re-fitting
|
||||
const observer = new ResizeObserver(() => {
|
||||
fitAndResize();
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
resizeObserverRef.current = observer;
|
||||
};
|
||||
|
||||
void mountTerminal();
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ==========================================
|
||||
// Re-fit when visibility changes
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
// Small delay allows CSS transitions to complete before fitting
|
||||
const id = setTimeout(fitAndResize, 50);
|
||||
return (): void => {
|
||||
clearTimeout(id);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [isVisible, fitAndResize]);
|
||||
|
||||
// ==========================================
|
||||
// Wire terminal input → sendInput
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
|
||||
const disposable = terminal.onData((data: string): void => {
|
||||
sendInput(data);
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [sendInput]);
|
||||
|
||||
// ==========================================
|
||||
// Create PTY session when connected
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || sessionId !== null) return;
|
||||
|
||||
const terminal = terminalRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
|
||||
let cols = 80;
|
||||
let rows = 24;
|
||||
|
||||
if (terminal && fitAddon) {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
cols = terminal.cols;
|
||||
rows = terminal.rows;
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}
|
||||
|
||||
createSession({ cols, rows });
|
||||
}, [isConnected, sessionId, createSession]);
|
||||
|
||||
// ==========================================
|
||||
// Update xterm theme when data-theme attribute changes
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (terminal) {
|
||||
terminal.options.theme = buildXtermTheme();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ==========================================
|
||||
// Cleanup on unmount
|
||||
// ==========================================
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
// Cleanup ResizeObserver
|
||||
resizeObserverRef.current?.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
|
||||
// Close PTY session
|
||||
closeSession();
|
||||
|
||||
// Dispose xterm terminal
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
isTerminalMountedRef.current = false;
|
||||
};
|
||||
}, [closeSession]);
|
||||
|
||||
// ==========================================
|
||||
// Render
|
||||
// ==========================================
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
backgroundColor: "var(--bg-deep)",
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={containerStyle}
|
||||
role="region"
|
||||
aria-label="Terminal"
|
||||
data-testid="xterminal-container"
|
||||
>
|
||||
{/* Status bar */}
|
||||
{!isConnected && !hasExited && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: "4px 12px",
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--warn)",
|
||||
backgroundColor: "var(--bg-deep)",
|
||||
zIndex: 10,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
Connecting to terminal...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exit overlay */}
|
||||
{hasExited && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--text)",
|
||||
backgroundColor: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={(): void => {
|
||||
setHasExited(false);
|
||||
setExitCode(null);
|
||||
if (isConnected) {
|
||||
const terminal = terminalRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
let cols = 80;
|
||||
let rows = 24;
|
||||
if (terminal && fitAddon) {
|
||||
try {
|
||||
cols = terminal.cols;
|
||||
rows = terminal.rows;
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}
|
||||
terminal?.clear();
|
||||
createSession({ cols, rows });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* xterm.js render target */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "4px 8px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
data-testid="xterm-viewport"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user