All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
405 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|