Files
headquarter/apps/web/src/components/terminal.tsx
T
Developer dd7696b5a4 refactor: extract CSS modules for terminal and git components (Task 2.2)
- Create TerminalComponent.module.css with terminal-* styles
- Create GitToolbar.module.css with git toolbar styles
- Create CommitDialog.module.css with commit dialog styles
- Create MergeDialog.module.css with merge dialog styles
- Create FileEditor.module.css with file editor styles
- Update all components to import their CSS modules
- Remove extracted rules from styles.css (~441 lines removed)

Quality gates: tsc (pass), eslint (pass), build (pass)
Refs: repo-restructure Task 2.2
2026-06-02 20:39:32 +00:00

311 lines
8.3 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { SerializeAddon } from "xterm-addon-serialize";
import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css";
import { useTerminalConnection } from "../hooks/use-terminal-connection";
import type {
ServerControlMessage,
TerminalConnectionState,
} from "../types/terminal";
import styles from "./features/terminal/TerminalComponent.module.css";
interface TerminalProps {
instanceId: string;
onClose?: () => void;
}
const STATUS_DOT_COLORS: Record<TerminalConnectionState["status"], string> = {
connecting: "var(--warning)",
connected: "var(--success)",
reconnecting: "var(--warning)",
disconnected: "var(--muted)",
};
function getStatusText(state: TerminalConnectionState): string {
switch (state.status) {
case "connecting":
return "Connecting...";
case "connected": {
if (state.latency !== null && state.latency >= 100) {
return `Slow (${state.latency}ms)`;
}
return "Connected";
}
case "reconnecting":
return `Reconnecting${state.attempt > 0 ? ` (${state.attempt})` : ""}`;
case "disconnected":
return state.error || "Disconnected";
}
}
export const TerminalComponent: React.FC<TerminalProps> = ({
instanceId,
onClose,
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [sessionEnded, setSessionEnded] = useState<{
reason: string;
message: string;
} | null>(null);
// Determine dark mode from document theme
const isDarkMode =
document.documentElement.getAttribute("data-theme") === "dark" ||
(document.documentElement.getAttribute("data-theme") === null &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const handleData = useCallback((data: Uint8Array) => {
// Data is already written by onLocalEcho or deduplication
// This callback is mainly for external consumers
void data;
}, []);
const handleLocalEcho = useCallback((data: string) => {
xtermRef.current?.write(data);
}, []);
const serializeFn = useCallback((): string | null => {
return serializeAddonRef.current?.serialize() ?? null;
}, []);
const handleRestoreScrollback = useCallback((content: string) => {
xtermRef.current?.write(content);
xtermRef.current?.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n");
}, []);
const handleControl = useCallback((msg: ServerControlMessage) => {
if (msg.type === "session_ended") {
const messages: Record<string, string> = {
process_exit: "The container process has exited.",
container_stop: "The container was stopped.",
timeout: "The session timed out due to inactivity.",
};
setSessionEnded({
reason: msg.reason,
message: messages[msg.reason] || "The session has ended.",
});
}
}, []);
const { state, sendInput, sendResize, reconnect } = useTerminalConnection({
instanceId,
onData: handleData,
onControl: handleControl,
onLocalEcho: handleLocalEcho,
serializeFn,
onRestoreScrollback: handleRestoreScrollback,
});
// Initialize xterm
useEffect(() => {
if (!terminalRef.current) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: isDarkMode
? {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: "#264f78",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
}
: {
background: "#fafafa",
foreground: "#333333",
cursor: "#333333",
selectionBackground: "#b4d7ff",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
});
const fitAddon = new FitAddon();
const serializeAddon = new SerializeAddon();
term.loadAddon(fitAddon);
term.loadAddon(serializeAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = term;
fitAddonRef.current = fitAddon;
serializeAddonRef.current = serializeAddon;
// Handle terminal input
const disposable = term.onData((data) => {
sendInput(data);
});
// Resize observer for container-level resize detection
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
let lastWidth = 0;
let lastHeight = 0;
const resizeObserver = new ResizeObserver((entries) => {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
resizeTimeout = setTimeout(() => {
resizeTimeout = null;
// Guard against internal xterm DOM changes that don't affect container size
if (
Math.abs(width - lastWidth) < 1 &&
Math.abs(height - lastHeight) < 1
) {
return;
}
lastWidth = width;
lastHeight = height;
const prevCols = term.cols;
const prevRows = term.rows;
fitAddon.fit();
if (term.cols !== prevCols || term.rows !== prevRows) {
sendResize(term.cols, term.rows);
}
}, 100);
});
resizeObserver.observe(terminalRef.current);
resizeObserverRef.current = resizeObserver;
return () => {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
disposable.dispose();
resizeObserver.disconnect();
term.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
serializeAddonRef.current = null;
};
}, [instanceId, isDarkMode, sendInput, sendResize]);
// Send initial terminal size once connected (and on reconnect)
useEffect(() => {
if (state.status === "connected" && xtermRef.current) {
const { cols, rows } = xtermRef.current;
sendResize(cols, rows);
}
}, [state.status, sendResize]);
return (
<div className={styles.terminalWrapper}>
<div className={styles.terminalHeader}>
<div className={styles.terminalStatus}>
<span
className={styles.statusDot}
style={{
backgroundColor: STATUS_DOT_COLORS[state.status],
}}
aria-label={`Terminal status: ${state.status}`}
title={
state.latency !== null
? `Latency: ${state.latency}ms`
: getStatusText(state)
}
/>
<span className={styles.statusText}>{getStatusText(state)}</span>
</div>
<div className={styles.terminalActions}>
{state.status === "disconnected" && (
<button
className="secondary-button small"
onClick={reconnect}
type="button"
>
Reconnect
</button>
)}
{onClose && (
<button className={styles.terminalClose} onClick={onClose} type="button">
Close
</button>
)}
</div>
</div>
{sessionEnded && (
<div className={styles.terminalOverlay}>
<div className={styles.terminalOverlayContent}>
<h3>Session Ended</h3>
<p>{sessionEnded.message}</p>
<div className={styles.terminalOverlayActions}>
<button
className="primary-button small"
onClick={() => {
setSessionEnded(null);
reconnect();
}}
type="button"
>
Reconnect
</button>
{onClose && (
<button
className="secondary-button small"
onClick={onClose}
type="button"
>
Go Back
</button>
)}
</div>
</div>
</div>
)}
{state.status === "reconnecting" && (
<div className={styles.terminalReconnectBanner}>
<span className={styles.spinner} />
{state.error}
</div>
)}
<div ref={terminalRef} className={styles.terminalContainer} />
</div>
);
};