Files
stack/apps/web/src/components/terminal/XTerminal.tsx
Jason Woltje 859dcfc4b7
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): implement multi-session terminal tab management (#520)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 03:18:35 +00:00

405 lines
12 KiB
TypeScript

"use client";
/**
* XTerminal component
*
* Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions)
* owns the WebSocket connection and session lifecycle. This component receives the
* sessionId and control functions as props and registers for output data specific
* to its session.
*
* Handles resize, copy/paste, theme, exit overlay, and reconnect.
*/
import { useEffect, useRef, useCallback } 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 type { SessionStatus } from "@/hooks/useTerminalSessions";
// ==========================================
// Types
// ==========================================
export interface XTerminalProps {
/** Session identifier (provided by useTerminalSessions) */
sessionId: string;
/** Send keyboard input to this session */
sendInput: (sessionId: string, data: string) => void;
/** Notify the server of a terminal resize */
resize: (sessionId: string, cols: number, rows: number) => void;
/** Close this PTY session */
closeSession: (sessionId: string) => void;
/**
* Register a callback to receive output for this session.
* Returns an unsubscribe function.
*/
registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void;
/** Whether the WebSocket is currently connected */
isConnected: boolean;
/** Current PTY process status */
sessionStatus: SessionStatus;
/** Exit code, populated when sessionStatus === 'exited' */
exitCode?: number;
/**
* Called when the user clicks the restart button after the session has exited.
* The parent is responsible for closing the old session and creating a new one.
*/
onRestart?: () => void;
/** 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 on tab switch) */
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.
*/
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.
* The parent provides the sessionId and control functions; this component
* registers for output data and manages the xterm.js instance lifecycle.
*/
export function XTerminal({
sessionId,
sendInput,
resize,
closeSession: _closeSession,
registerOutputCallback,
isConnected,
sessionStatus,
exitCode,
onRestart,
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 = sessionStatus === "exited";
// ==========================================
// Fit helper
// ==========================================
const fitAndResize = useCallback((): void => {
const fitAddon = fitAddonRef.current;
const terminal = terminalRef.current;
if (!fitAddon || !terminal) return;
try {
fitAddon.fit();
resize(sessionId, terminal.cols, terminal.rows);
} catch {
// Ignore fit errors (e.g., when container has zero dimensions)
}
}, [resize, sessionId]);
// ==========================================
// 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;
};
// Intentionally empty dep array — mount once only
}, []);
// ==========================================
// Register output callback for this session
// ==========================================
useEffect(() => {
const unregister = registerOutputCallback(sessionId, (data: string) => {
terminalRef.current?.write(data);
});
return unregister;
}, [sessionId, registerOutputCallback]);
// ==========================================
// 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(sessionId, data);
});
return (): void => {
disposable.dispose();
};
}, [sendInput, sessionId]);
// ==========================================
// 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;
// Dispose xterm terminal
terminalRef.current?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
isTerminalMountedRef.current = false;
};
}, []);
// ==========================================
// Restart handler
// ==========================================
const handleRestart = useCallback((): void => {
const terminal = terminalRef.current;
if (terminal) {
terminal.clear();
}
// Notify parent to close old session and create a new one
onRestart?.();
}, [onRestart]);
// ==========================================
// 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"
data-session-id={sessionId}
>
{/* Status bar — show when not connected and not exited */}
{!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={handleRestart}
>
Restart terminal{exitCode !== undefined ? ` (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>
);
}