dd7696b5a4
- 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
311 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
};
|