feat(web): implement multi-session terminal tab management (#520)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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>
This commit was merged in pull request #520.
This commit is contained in:
@@ -3,29 +3,55 @@
|
||||
/**
|
||||
* XTerminal component
|
||||
*
|
||||
* Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace.
|
||||
* Handles resize, copy/paste, theme, and session lifecycle.
|
||||
* 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, useState } from "react";
|
||||
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 { useTerminal } from "@/hooks/useTerminal";
|
||||
import type { SessionStatus } from "@/hooks/useTerminalSessions";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export interface XTerminalProps {
|
||||
/** Authentication token for the WebSocket connection */
|
||||
token: string;
|
||||
/** 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) */
|
||||
/** Whether the terminal is visible (used to trigger re-fit on tab switch) */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
@@ -45,8 +71,6 @@ function getCssVar(varName: string, fallback: string): string {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -82,11 +106,20 @@ function buildXtermTheme(): Record<string, string> {
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* XTerminal renders a real PTY terminal powered by xterm.js,
|
||||
* connected to the backend /terminal WebSocket namespace.
|
||||
* 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({
|
||||
token,
|
||||
sessionId,
|
||||
sendInput,
|
||||
resize,
|
||||
closeSession: _closeSession,
|
||||
registerOutputCallback,
|
||||
isConnected,
|
||||
sessionStatus,
|
||||
exitCode,
|
||||
onRestart,
|
||||
className = "",
|
||||
style,
|
||||
isVisible = true,
|
||||
@@ -97,41 +130,7 @@ export function XTerminal({
|
||||
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,
|
||||
});
|
||||
const hasExited = sessionStatus === "exited";
|
||||
|
||||
// ==========================================
|
||||
// Fit helper
|
||||
@@ -144,11 +143,11 @@ export function XTerminal({
|
||||
|
||||
try {
|
||||
fitAddon.fit();
|
||||
resize(terminal.cols, terminal.rows);
|
||||
resize(sessionId, terminal.cols, terminal.rows);
|
||||
} catch {
|
||||
// Ignore fit errors (e.g., when container has zero dimensions)
|
||||
}
|
||||
}, [resize]);
|
||||
}, [resize, sessionId]);
|
||||
|
||||
// ==========================================
|
||||
// Mount xterm.js terminal (client-only)
|
||||
@@ -217,8 +216,20 @@ export function XTerminal({
|
||||
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
|
||||
// ==========================================
|
||||
@@ -243,39 +254,13 @@ export function XTerminal({
|
||||
if (!terminal) return;
|
||||
|
||||
const disposable = terminal.onData((data: string): void => {
|
||||
sendInput(data);
|
||||
sendInput(sessionId, 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]);
|
||||
}, [sendInput, sessionId]);
|
||||
|
||||
// ==========================================
|
||||
// Update xterm theme when data-theme attribute changes
|
||||
@@ -309,16 +294,26 @@ export function XTerminal({
|
||||
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]);
|
||||
}, []);
|
||||
|
||||
// ==========================================
|
||||
// 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
|
||||
@@ -339,8 +334,9 @@ export function XTerminal({
|
||||
role="region"
|
||||
aria-label="Terminal"
|
||||
data-testid="xterminal-container"
|
||||
data-session-id={sessionId}
|
||||
>
|
||||
{/* Status bar */}
|
||||
{/* Status bar — show when not connected and not exited */}
|
||||
{!isConnected && !hasExited && (
|
||||
<div
|
||||
style={{
|
||||
@@ -385,28 +381,9 @@ export function XTerminal({
|
||||
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 });
|
||||
}
|
||||
}}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""}
|
||||
Restart terminal{exitCode !== undefined ? ` (exit ${exitCode.toString()})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user