feat(web): integrate xterm.js with WebSocket terminal backend (#518)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was 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 #518.
This commit is contained in:
2026-02-26 02:55:53 +00:00
committed by jason.woltje
parent 8128eb7fbe
commit 417c6ab49c
9 changed files with 1694 additions and 100 deletions

View File

@@ -1,9 +1,18 @@
import type { ReactElement, CSSProperties } from "react";
"use client";
export interface TerminalLine {
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
content: string;
}
/**
* TerminalPanel
*
* Shell panel that wraps the XTerminal component with a tab bar and close button.
* Replaces the former mock terminal with a real xterm.js PTY terminal.
*/
import type { ReactElement, CSSProperties } from "react";
import { XTerminal } from "./XTerminal";
// ==========================================
// Types (retained for backwards compatibility)
// ==========================================
export interface TerminalTab {
id: string;
@@ -16,51 +25,16 @@ export interface TerminalPanelProps {
tabs?: TerminalTab[];
activeTab?: string;
onTabChange?: (id: string) => void;
lines?: TerminalLine[];
/** Authentication token for the WebSocket connection */
token?: string;
className?: string;
}
const defaultTabs: TerminalTab[] = [
{ id: "main", label: "main" },
{ id: "build", label: "build" },
{ id: "logs", label: "logs" },
];
const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
const blinkKeyframes = `
@keyframes ms-terminal-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`;
let blinkStyleInjected = false;
function ensureBlinkStyle(): void {
if (blinkStyleInjected || typeof document === "undefined") return;
const styleEl = document.createElement("style");
styleEl.textContent = blinkKeyframes;
document.head.appendChild(styleEl);
blinkStyleInjected = true;
}
function getLineColor(type: TerminalLine["type"]): string {
switch (type) {
case "prompt":
return "var(--success)";
case "command":
return "var(--text-2)";
case "output":
return "var(--muted)";
case "error":
return "var(--danger)";
case "warning":
return "var(--warn)";
case "success":
return "var(--success)";
default:
return "var(--muted)";
}
}
// ==========================================
// Component
// ==========================================
export function TerminalPanel({
open,
@@ -68,11 +42,9 @@ export function TerminalPanel({
tabs,
activeTab,
onTabChange,
lines = [],
token = "",
className = "",
}: TerminalPanelProps): ReactElement {
ensureBlinkStyle();
const resolvedTabs = tabs ?? defaultTabs;
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
@@ -109,21 +81,10 @@ export function TerminalPanel({
const bodyStyle: CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "10px 16px",
fontFamily: "var(--mono)",
fontSize: "0.78rem",
lineHeight: 1.6,
};
const cursorStyle: CSSProperties = {
display: "inline-block",
width: 7,
height: 14,
background: "var(--success)",
marginLeft: 2,
animation: "ms-terminal-blink 1s step-end infinite",
verticalAlign: "text-bottom",
overflow: "hidden",
display: "flex",
flexDirection: "column",
minHeight: 0,
};
return (
@@ -208,7 +169,7 @@ export function TerminalPanel({
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
{/* Close icon — simple X using SVG */}
{/* Close icon */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M1 1L11 11M11 1L1 11"
@@ -221,36 +182,9 @@ export function TerminalPanel({
</div>
</div>
{/* Body */}
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
{lines.map((line, index) => {
const isLast = index === lines.length - 1;
const lineStyle: CSSProperties = {
display: "flex",
gap: 8,
};
const contentStyle: CSSProperties = {
color: getLineColor(line.type),
};
return (
<div key={index} style={lineStyle}>
<span style={contentStyle}>
{line.content}
{isLast && <span aria-hidden="true" style={cursorStyle} />}
</span>
</div>
);
})}
{/* Show cursor even when no lines */}
{lines.length === 0 && (
<div style={{ display: "flex", gap: 8 }}>
<span style={{ color: "var(--success)" }}>
<span aria-hidden="true" style={cursorStyle} />
</span>
</div>
)}
{/* Terminal body */}
<div style={bodyStyle}>
<XTerminal token={token} isVisible={open} style={{ flex: 1, minHeight: 0 }} />
</div>
</div>
);