c49bb028c4
The ResizeObserver triggered fit() which changed canvas dimensions, triggering the observer again in an infinite loop. We already have window resize handling and delayed fit() calls, so the observer was redundant.
455 lines
13 KiB
TypeScript
455 lines
13 KiB
TypeScript
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
import { Terminal } from "xterm";
|
|
import { FitAddon } from "xterm-addon-fit";
|
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
|
import "xterm/css/xterm.css";
|
|
|
|
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
|
|
|
|
interface TerminalProps {
|
|
instanceId: string;
|
|
onClose?: () => void;
|
|
isMobile?: boolean;
|
|
activeModifier?: ModifierKey | null;
|
|
onModifierChange?: (modifier: ModifierKey | null) => void;
|
|
onTerminalReady?: (
|
|
sendData: (data: string) => void,
|
|
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
|
|
focusInput: () => void,
|
|
changeFontSize: (delta: number) => void
|
|
) => void;
|
|
}
|
|
|
|
const FONT_SIZE_KEY = "terminal-font-size";
|
|
const MIN_FONT_SIZE = 10;
|
|
const MAX_FONT_SIZE = 24;
|
|
const RECONNECT_ATTEMPTS = 3;
|
|
const RECONNECT_DELAY_BASE = 1000;
|
|
|
|
export const TerminalComponent: React.FC<TerminalProps> = ({
|
|
instanceId,
|
|
onClose,
|
|
isMobile = false,
|
|
activeModifier,
|
|
onModifierChange,
|
|
onTerminalReady,
|
|
}) => {
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const termRef = useRef<Terminal | null>(null);
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
const reconnectAttemptsRef = useRef(0);
|
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
|
onTerminalReadyRef.current = onTerminalReady;
|
|
const [status, setStatus] = useState<
|
|
"connecting" | "connected" | "disconnected" | "error"
|
|
>("connecting");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const activeModifierRef = useRef(activeModifier);
|
|
activeModifierRef.current = activeModifier;
|
|
const [fontSize, setFontSize] = useState(() => {
|
|
if (typeof window === "undefined") return isMobile ? 16 : 14;
|
|
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
|
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
|
|
});
|
|
|
|
const calculateFontSize = useCallback(() => {
|
|
if (!isMobile) return fontSize;
|
|
const vw = window.innerWidth;
|
|
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
|
|
return Math.round(calculated);
|
|
}, [isMobile, fontSize]);
|
|
|
|
const connectWebSocket = useCallback(() => {
|
|
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
setStatus("connected");
|
|
setError(null);
|
|
reconnectAttemptsRef.current = 0;
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (!termRef.current) return;
|
|
|
|
if (event.data instanceof Blob) {
|
|
event.data.arrayBuffer().then((buffer) => {
|
|
const data = new Uint8Array(buffer);
|
|
termRef.current?.write(data);
|
|
});
|
|
} else if (typeof event.data === "string") {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === "status" && msg.status === "connected") {
|
|
setStatus("connected");
|
|
}
|
|
} catch {
|
|
termRef.current?.write(event.data);
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
setStatus("disconnected");
|
|
if (event.code !== 1000) {
|
|
setError(`Connection closed (code: ${event.code})`);
|
|
|
|
// Attempt reconnection
|
|
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
|
reconnectAttemptsRef.current++;
|
|
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
|
setTimeout(() => {
|
|
if (document.visibilityState !== "hidden") {
|
|
connectWebSocket();
|
|
}
|
|
}, delay);
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setStatus("error");
|
|
setError("WebSocket error");
|
|
};
|
|
|
|
return ws;
|
|
}, [instanceId]);
|
|
|
|
useEffect(() => {
|
|
if (!terminalRef.current) return;
|
|
|
|
// Initialize terminal
|
|
const currentFontSize = calculateFontSize();
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: currentFontSize,
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
theme: {
|
|
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",
|
|
},
|
|
});
|
|
|
|
termRef.current = term;
|
|
|
|
const fitAddon = new FitAddon();
|
|
fitAddonRef.current = fitAddon;
|
|
term.loadAddon(fitAddon);
|
|
term.loadAddon(new WebLinksAddon());
|
|
|
|
term.open(terminalRef.current);
|
|
|
|
// Connect WebSocket
|
|
const ws = connectWebSocket();
|
|
|
|
// Fit after layout settles - use rAF + timeout to ensure DOM is ready
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
fitAddon.fit();
|
|
// Force a second fit after layout fully settles
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
const { cols, rows } = term;
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
})
|
|
);
|
|
}
|
|
}, 500);
|
|
|
|
// Fit after mobile header auto-hides (3s delay + 0.3s transition)
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
const { cols, rows } = term;
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
})
|
|
);
|
|
}
|
|
}, 4000);
|
|
});
|
|
});
|
|
|
|
// Handle terminal input
|
|
term.onData((data) => {
|
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
|
|
// Apply active modifier to single-character input
|
|
const modifier = activeModifierRef.current;
|
|
if (modifier && data.length === 1) {
|
|
const modified = applyModifierToChar(data, modifier);
|
|
if (modified) {
|
|
ws.send(modified);
|
|
onModifierChange?.(null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
ws.send(data);
|
|
});
|
|
|
|
// Handle resize with debounce
|
|
let resizeTimeout: ReturnType<typeof setTimeout>;
|
|
const handleResize = () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
fitAddon.fit();
|
|
const { cols, rows } = term;
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
})
|
|
);
|
|
}
|
|
}, 250);
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
// Notify parent about terminal readiness
|
|
if (onTerminalReadyRef.current) {
|
|
const sendData = (data: string) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(data);
|
|
}
|
|
};
|
|
const focusInput = () => {
|
|
termRef.current?.focus();
|
|
};
|
|
const changeFontSize = (delta: number) => {
|
|
handleFontSizeChange(delta);
|
|
};
|
|
onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize);
|
|
}
|
|
|
|
// Visibility API for reconnection
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
|
|
reconnectAttemptsRef.current = 0;
|
|
connectWebSocket();
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
clearTimeout(resizeTimeout);
|
|
window.removeEventListener("resize", handleResize);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
ws.close();
|
|
term.dispose();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [instanceId, connectWebSocket]);
|
|
|
|
// Update parent about status changes
|
|
useEffect(() => {
|
|
if (onTerminalReady && termRef.current) {
|
|
const sendData = (data: string) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(data);
|
|
}
|
|
};
|
|
const focusInput = () => {
|
|
termRef.current?.focus();
|
|
};
|
|
const changeFontSize = (delta: number) => {
|
|
handleFontSizeChange(delta);
|
|
};
|
|
onTerminalReady(sendData, status, focusInput, changeFontSize);
|
|
}
|
|
}, [status, onTerminalReady]);
|
|
|
|
const handleFontSizeChange = (delta: number) => {
|
|
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
|
|
setFontSize(newSize);
|
|
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
|
if (termRef.current && fitAddonRef.current) {
|
|
termRef.current.options.fontSize = newSize;
|
|
requestAnimationFrame(() => {
|
|
if (termRef.current && fitAddonRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
const { cols, rows } = termRef.current;
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
})
|
|
);
|
|
}
|
|
} catch {
|
|
// Ignore fit errors during re-initialization
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
if (!termRef.current) return;
|
|
const selection = termRef.current.getSelection();
|
|
if (selection) {
|
|
try {
|
|
await navigator.clipboard.writeText(selection);
|
|
} catch {
|
|
// Fallback for older browsers
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = selection;
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(textarea);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePaste = async () => {
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(text);
|
|
}
|
|
} catch {
|
|
// Clipboard API not available
|
|
}
|
|
};
|
|
|
|
// Focus terminal on mobile to keep keyboard open
|
|
const handleTerminalClick = () => {
|
|
if (isMobile && termRef.current) {
|
|
termRef.current.focus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
|
|
<div className="terminal-header">
|
|
<div className="terminal-header-left">
|
|
<div className="terminal-status">
|
|
<span
|
|
className={`status-dot ${status}`}
|
|
aria-label={`Terminal status: ${status}`}
|
|
/>
|
|
<span className="status-text">
|
|
{reconnectAttemptsRef.current > 0 && status !== "connected"
|
|
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
|
|
: status}
|
|
</span>
|
|
</div>
|
|
{isMobile && (
|
|
<>
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={handleCopy}
|
|
type="button"
|
|
aria-label="Copy selection"
|
|
>
|
|
Copy
|
|
</button>
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={handlePaste}
|
|
type="button"
|
|
aria-label="Paste from clipboard"
|
|
>
|
|
Paste
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="terminal-header-right">
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={() => handleFontSizeChange(-1)}
|
|
type="button"
|
|
aria-label="Decrease font size"
|
|
>
|
|
A-
|
|
</button>
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={() => handleFontSizeChange(1)}
|
|
type="button"
|
|
aria-label="Increase font size"
|
|
>
|
|
A+
|
|
</button>
|
|
{onClose && (
|
|
<button className="terminal-close" onClick={onClose} type="button">
|
|
Close
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{error && (
|
|
<div className="terminal-error">
|
|
{error}
|
|
{status === "error" && (
|
|
<button
|
|
className="terminal-reconnect"
|
|
onClick={() => {
|
|
reconnectAttemptsRef.current = 0;
|
|
connectWebSocket();
|
|
}}
|
|
type="button"
|
|
>
|
|
Reconnect
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={terminalRef}
|
|
className="terminal-container"
|
|
onClick={handleTerminalClick}
|
|
/>
|
|
{isMobile && (
|
|
<input
|
|
ref={hiddenInputRef}
|
|
type="text"
|
|
className="terminal-hidden-input"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|