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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user