7603b739cb
Handle selected-output context menus during capture so xterm and the browser cannot open a competing menu.
1074 lines
30 KiB
TypeScript
1074 lines
30 KiB
TypeScript
import React, {
|
|
useEffect,
|
|
useImperativeHandle,
|
|
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";
|
|
|
|
export interface TerminalProps {
|
|
instanceId: string;
|
|
sessionId?: string;
|
|
onClose?: () => void;
|
|
isMobile?: boolean;
|
|
showControls?: boolean;
|
|
activeModifier?: ModifierKey | null;
|
|
onModifierChange?: (modifier: ModifierKey | null) => void;
|
|
onTerminalReady?: (
|
|
sessionId: string | undefined,
|
|
sendData: (data: string) => void,
|
|
connectionStatus:
|
|
| "connecting"
|
|
| "connected"
|
|
| "disconnected"
|
|
| "error"
|
|
| "resetting",
|
|
focusInput: () => void,
|
|
changeFontSize: (delta: number) => void,
|
|
) => void;
|
|
}
|
|
|
|
export interface TerminalRef {
|
|
fit: () => void;
|
|
focus: () => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
const FONT_SIZE_KEY = "terminal-font-size";
|
|
const MIN_FONT_SIZE = 4;
|
|
const MAX_FONT_SIZE = 24;
|
|
const RECONNECT_ATTEMPTS = 3;
|
|
const RECONNECT_DELAY_BASE = 1000;
|
|
const BRACKETED_PASTE_ENABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x68];
|
|
const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x6c];
|
|
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
|
|
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
|
|
|
|
export function getTerminalScrollbackLimit(isMobile: boolean): number {
|
|
return isMobile ? 10_000 : 0;
|
|
}
|
|
|
|
export function isCurrentWebSocket(
|
|
current: WebSocket | null,
|
|
candidate: WebSocket,
|
|
): boolean {
|
|
return current === candidate;
|
|
}
|
|
|
|
export function shouldRetryWebSocketClose(code: number, reason: string): boolean {
|
|
return code !== 1000 && !(code === 4000 && reason === "New connection established");
|
|
}
|
|
|
|
export function shouldShowTerminalCopyMenu(selection: string): boolean {
|
|
return selection.length > 0;
|
|
}
|
|
|
|
function copyTextWithFallback(text: string): void {
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = text;
|
|
textarea.setAttribute("readonly", "");
|
|
textarea.style.position = "fixed";
|
|
textarea.style.opacity = "0";
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand("copy");
|
|
textarea.remove();
|
|
}
|
|
|
|
function matchesByteSequence(
|
|
data: Uint8Array,
|
|
start: number,
|
|
sequence: readonly number[],
|
|
): boolean {
|
|
return sequence.every((byte, index) => data[start + index] === byte);
|
|
}
|
|
|
|
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
(
|
|
{
|
|
instanceId,
|
|
sessionId,
|
|
onClose,
|
|
isMobile = false,
|
|
showControls = true,
|
|
activeModifier,
|
|
onModifierChange,
|
|
onTerminalReady,
|
|
},
|
|
ref,
|
|
) => {
|
|
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 bracketedPasteEnabledRef = useRef(false);
|
|
const pasteTextRef = useRef<(text: string) => void>(() => {});
|
|
const reconnectAttemptsRef = useRef(0);
|
|
const reconnectTimerRef = useRef<number | null>(null);
|
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
|
onTerminalReadyRef.current = onTerminalReady;
|
|
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
|
|
const [status, setStatus] = useState<
|
|
"connecting" | "connected" | "disconnected" | "error" | "resetting"
|
|
>("connecting");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
|
const [copyMenu, setCopyMenu] = useState<{
|
|
x: number;
|
|
y: number;
|
|
text: string;
|
|
} | null>(null);
|
|
const activeModifierRef = useRef(activeModifier);
|
|
activeModifierRef.current = activeModifier;
|
|
const [fontSize, setFontSize] = useState(() => {
|
|
if (typeof window === "undefined") return isMobile ? 8 : 8;
|
|
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
|
if (stored) {
|
|
const parsed = parseInt(stored, 10);
|
|
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed));
|
|
}
|
|
return isMobile ? 8 : 8;
|
|
});
|
|
const lastPingRef = useRef<number>(0);
|
|
const heartbeatCheckRef = useRef<number | null>(null);
|
|
const isUnmountingRef = useRef(false);
|
|
const permanentErrorRef = useRef<string | null>(null);
|
|
|
|
const calculateFontSize = useCallback(() => {
|
|
return fontSize;
|
|
}, [fontSize]);
|
|
|
|
const clearReconnectTimer = useCallback(() => {
|
|
if (reconnectTimerRef.current !== null) {
|
|
window.clearTimeout(reconnectTimerRef.current);
|
|
reconnectTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const connectWebSocket = useCallback(() => {
|
|
const currentWs = wsRef.current;
|
|
if (
|
|
currentWs?.readyState === WebSocket.CONNECTING ||
|
|
currentWs?.readyState === WebSocket.OPEN
|
|
) {
|
|
return currentWs;
|
|
}
|
|
clearReconnectTimer();
|
|
|
|
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
const wsPath = sessionId
|
|
? `/ws/tool-instances/${instanceId}/terminal/${sessionId}`
|
|
: `/ws/tool-instances/${instanceId}/terminal`;
|
|
const wsUrl = `${wsProtocol}//${wsHost}${wsPath}`;
|
|
|
|
// WebSocket connection established
|
|
const ws = new WebSocket(wsUrl);
|
|
ws.binaryType = "arraybuffer";
|
|
wsRef.current = ws;
|
|
|
|
let bracketedPasteControlTail = new Uint8Array(0);
|
|
const updateBracketedPasteMode = (data: Uint8Array) => {
|
|
const combined = new Uint8Array(
|
|
bracketedPasteControlTail.length + data.length,
|
|
);
|
|
combined.set(bracketedPasteControlTail);
|
|
combined.set(data, bracketedPasteControlTail.length);
|
|
|
|
for (let index = 0; index < combined.length; index++) {
|
|
if (
|
|
index + BRACKETED_PASTE_ENABLE_SEQUENCE.length <= combined.length &&
|
|
matchesByteSequence(
|
|
combined,
|
|
index,
|
|
BRACKETED_PASTE_ENABLE_SEQUENCE,
|
|
)
|
|
) {
|
|
bracketedPasteEnabledRef.current = true;
|
|
} else if (
|
|
index + BRACKETED_PASTE_DISABLE_SEQUENCE.length <= combined.length &&
|
|
matchesByteSequence(
|
|
combined,
|
|
index,
|
|
BRACKETED_PASTE_DISABLE_SEQUENCE,
|
|
)
|
|
) {
|
|
bracketedPasteEnabledRef.current = false;
|
|
}
|
|
}
|
|
|
|
bracketedPasteControlTail = combined.slice(
|
|
Math.max(0, combined.length - BRACKETED_PASTE_CONTROL_TAIL_LENGTH),
|
|
);
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
if (!isCurrentWebSocket(wsRef.current, ws)) {
|
|
ws.close(1000, "Superseded connection");
|
|
return;
|
|
}
|
|
|
|
setStatus("connected");
|
|
setError(null);
|
|
reconnectAttemptsRef.current = 0;
|
|
lastPingRef.current = Date.now();
|
|
|
|
// Send current terminal size immediately on connect
|
|
if (termRef.current) {
|
|
const { cols, rows } = termRef.current;
|
|
// Only send if we have valid dimensions
|
|
if (cols > 0 && rows > 0) {
|
|
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
}
|
|
}
|
|
|
|
// Start heartbeat check
|
|
if (heartbeatCheckRef.current) {
|
|
window.clearInterval(heartbeatCheckRef.current);
|
|
}
|
|
heartbeatCheckRef.current = window.setInterval(() => {
|
|
const elapsed = Date.now() - lastPingRef.current;
|
|
if (elapsed > 60000) {
|
|
// No ping for 60 seconds, connection may be dead
|
|
ws.close(4000, "Heartbeat timeout");
|
|
}
|
|
}, 30000);
|
|
};
|
|
|
|
// Flow control: accumulate processed bytes and send ack
|
|
let ackAccumulator = 0;
|
|
const ACK_THRESHOLD = 4096;
|
|
let ackTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const flushAck = () => {
|
|
if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
|
|
ackAccumulator = 0;
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (!isCurrentWebSocket(wsRef.current, ws) || !termRef.current) return;
|
|
|
|
if (event.data instanceof ArrayBuffer) {
|
|
const data = new Uint8Array(event.data);
|
|
updateBracketedPasteMode(data);
|
|
termRef.current.write(data);
|
|
|
|
// Flow control: accumulate processed bytes
|
|
ackAccumulator += data.length;
|
|
if (ackAccumulator >= ACK_THRESHOLD) {
|
|
flushAck();
|
|
} else if (!ackTimeout) {
|
|
ackTimeout = setTimeout(() => {
|
|
ackTimeout = null;
|
|
flushAck();
|
|
}, 100);
|
|
}
|
|
} else if (typeof event.data === "string") {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === "status") {
|
|
if (msg.status === "connected") {
|
|
setStatus("connected");
|
|
setError(null);
|
|
// Refit after reset/reconnect but do NOT clear the
|
|
// terminal: the server keeps the session buffer, and
|
|
// clearing would erase it when the user switches back
|
|
// to an already-connected session.
|
|
if (termRef.current) {
|
|
requestAnimationFrame(() => {
|
|
if (fitAddonRef.current && termRef.current) {
|
|
fitAddonRef.current.fit();
|
|
const { cols, rows } = termRef.current;
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
currentWs.send(
|
|
JSON.stringify({ type: "resize", cols, rows }),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} else if (msg.status === "resetting") {
|
|
setStatus("resetting");
|
|
}
|
|
} else if (msg.type === "ping") {
|
|
// Respond with pong and update last ping time
|
|
lastPingRef.current = Date.now();
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
}
|
|
}
|
|
} catch {
|
|
termRef.current?.write(event.data);
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
if (!isCurrentWebSocket(wsRef.current, ws)) return;
|
|
wsRef.current = null;
|
|
if (ackTimeout) window.clearTimeout(ackTimeout);
|
|
|
|
// Clean up heartbeat check
|
|
if (heartbeatCheckRef.current) {
|
|
window.clearInterval(heartbeatCheckRef.current);
|
|
heartbeatCheckRef.current = null;
|
|
}
|
|
|
|
// Permanent errors: do not retry
|
|
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
|
|
const reason = event.reason || `Instance error (code: ${event.code})`;
|
|
setStatus("error");
|
|
setError(reason);
|
|
permanentErrorRef.current = reason;
|
|
return;
|
|
}
|
|
|
|
if (!shouldRetryWebSocketClose(event.code, event.reason)) {
|
|
setStatus("disconnected");
|
|
return;
|
|
}
|
|
|
|
// Transient errors: attempt reconnection.
|
|
setStatus("disconnected");
|
|
setError(`Connection closed (code: ${event.code})`);
|
|
|
|
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
|
reconnectAttemptsRef.current++;
|
|
const delay =
|
|
RECONNECT_DELAY_BASE *
|
|
Math.pow(2, reconnectAttemptsRef.current - 1);
|
|
clearReconnectTimer();
|
|
reconnectTimerRef.current = window.setTimeout(() => {
|
|
reconnectTimerRef.current = null;
|
|
if (
|
|
!isUnmountingRef.current &&
|
|
document.visibilityState !== "hidden" &&
|
|
wsRef.current === null
|
|
) {
|
|
connectWebSocket();
|
|
}
|
|
}, delay);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
if (!isCurrentWebSocket(wsRef.current, ws)) return;
|
|
setStatus("error");
|
|
setError("WebSocket error");
|
|
};
|
|
|
|
return ws;
|
|
}, [clearReconnectTimer, instanceId, sessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!terminalRef.current) return;
|
|
isUnmountingRef.current = false;
|
|
permanentErrorRef.current = null;
|
|
|
|
// Initialize terminal
|
|
const currentFontSize = calculateFontSize();
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: currentFontSize,
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
lineHeight: 1.2,
|
|
letterSpacing: 0,
|
|
allowTransparency: false,
|
|
// Desktop tools repaint in place, so retaining their normal buffer
|
|
// creates stale frames that native wheel scrolling can revisit. Mobile
|
|
// instead uses its custom touch handler to scroll normal-buffer output,
|
|
// which requires retained history.
|
|
scrollback: getTerminalScrollbackLimit(isMobile),
|
|
ignoreBracketedPasteMode: false,
|
|
fastScrollSensitivity: 0,
|
|
scrollSensitivity: 0,
|
|
smoothScrollDuration: 0,
|
|
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());
|
|
|
|
// NOTE: WebGL renderer disabled.
|
|
// The WebGL addon causes black-on-black rendering artifacts with
|
|
// tmux/vim reverse-video (inverse color) sequences on desktop.
|
|
// Mobile already uses the DOM renderer (WebGL fails there), which
|
|
// handles these color attributes correctly. The DOM renderer is
|
|
// fast enough for typical terminal workloads.
|
|
// See: xterm.js WebGL known issues with reverse video / minimumContrastRatio
|
|
|
|
const container = terminalRef.current;
|
|
|
|
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
|
let lastSentCols = 0;
|
|
let lastSentRows = 0;
|
|
const fitTerminal = () => {
|
|
if (!fitAddonRef.current || !termRef.current) return;
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
} catch {
|
|
// Ignore fit errors during initialization
|
|
return;
|
|
}
|
|
const { cols, rows } = termRef.current;
|
|
// Only send resize when dimensions actually changed
|
|
if (
|
|
cols > 0 &&
|
|
rows > 0 &&
|
|
(cols !== lastSentCols || rows !== lastSentRows)
|
|
) {
|
|
lastSentCols = cols;
|
|
lastSentRows = rows;
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Open xterm first (must happen before fit)
|
|
term.open(container);
|
|
term.focus();
|
|
connectWebSocket();
|
|
|
|
pasteTextRef.current = (text: string) => {
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
|
|
|
if (bracketedPasteEnabledRef.current) {
|
|
const normalizedText = text.replace(/\r\n|\r|\n/g, "\r");
|
|
currentWs.send(`\x1b[200~${normalizedText}\x1b[201~`);
|
|
return;
|
|
}
|
|
|
|
term.paste(text);
|
|
};
|
|
|
|
const handleBrowserCopy = (event: ClipboardEvent) => {
|
|
const selection = term.getSelection();
|
|
if (!selection) return;
|
|
|
|
event.preventDefault();
|
|
event.clipboardData?.setData("text/plain", selection);
|
|
};
|
|
const handleTerminalContextMenu = (event: MouseEvent) => {
|
|
const selection = term.getSelection();
|
|
if (!shouldShowTerminalCopyMenu(selection)) {
|
|
setCopyMenu(null);
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
|
|
};
|
|
|
|
const handleBrowserPaste = (event: ClipboardEvent) => {
|
|
const text = event.clipboardData?.getData("text/plain");
|
|
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
pasteTextRef.current(text);
|
|
};
|
|
container.addEventListener("copy", handleBrowserCopy, true);
|
|
container.addEventListener("contextmenu", handleTerminalContextMenu, true);
|
|
container.addEventListener("paste", handleBrowserPaste, true);
|
|
|
|
// Mobile touch scroll.
|
|
// In normal mode xterm.js has a scrollable viewport; in alternate
|
|
// screen (tmux/vim) there is no scrollback and the only way to
|
|
// scroll is to send mouse-wheel protocol sequences to the
|
|
// application. We detect the active buffer by reference equality
|
|
// (term.buffer.active === term.buffer.alternate) because the
|
|
// `type` string can be unreliable in some xterm.js versions.
|
|
let touchCleanup: (() => void) | undefined;
|
|
if (isMobile) {
|
|
let startY = 0;
|
|
let startX = 0;
|
|
let isScrolling = false;
|
|
let scrollPending = 0;
|
|
const WHEEL_DISTANCE = 12;
|
|
|
|
const flushScroll = (force = false) => {
|
|
if (scrollPending === 0) return;
|
|
if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return;
|
|
const direction = Math.sign(scrollPending);
|
|
const steps = Math.max(
|
|
1,
|
|
Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE),
|
|
);
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
|
|
const buf = termRef.current.buffer.active;
|
|
const col = buf.cursorX + 1;
|
|
const row = buf.cursorY + 1;
|
|
const btn = direction > 0 ? 64 : 65;
|
|
for (let i = 0; i < steps; i++) {
|
|
ws.send(`\x1b[<${btn};${col};${row}M`);
|
|
}
|
|
}
|
|
scrollPending = 0;
|
|
};
|
|
|
|
const isAlternateScreen = () => {
|
|
const term = termRef.current;
|
|
if (!term) return false;
|
|
return term.buffer.active === term.buffer.alternate;
|
|
};
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
if (e.touches.length === 1) {
|
|
startY = e.touches[0].clientY;
|
|
startX = e.touches[0].clientX;
|
|
isScrolling = false;
|
|
scrollPending = 0;
|
|
}
|
|
};
|
|
const onTouchMove = (e: TouchEvent) => {
|
|
if (e.touches.length !== 1) return;
|
|
const touch = e.touches[0];
|
|
const deltaY = startY - touch.clientY;
|
|
const deltaX = Math.abs(startX - touch.clientX);
|
|
|
|
// Decide early whether this is a vertical scroll gesture.
|
|
if (!isScrolling) {
|
|
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 2) {
|
|
isScrolling = true;
|
|
}
|
|
}
|
|
if (!isScrolling) return;
|
|
|
|
// Take over the gesture so the page/toolbar never scrolls.
|
|
e.preventDefault();
|
|
|
|
if (isAlternateScreen()) {
|
|
// Alternate screen (tmux/vim): accumulate the swipe and
|
|
// send SGR 1006 mouse-wheel events in steps.
|
|
scrollPending += deltaY;
|
|
flushScroll();
|
|
} else if (termRef.current) {
|
|
// Normal buffer: let xterm.js scroll its own viewport by
|
|
// the number of lines corresponding to the swipe distance.
|
|
const lineHeight =
|
|
termRef.current.options.fontSize != null
|
|
? (termRef.current.options.fontSize as number) * 1.2
|
|
: 10;
|
|
const lines = Math.round(deltaY / lineHeight);
|
|
if (lines !== 0) {
|
|
termRef.current.scrollLines(lines);
|
|
}
|
|
}
|
|
startY = touch.clientY;
|
|
};
|
|
const onTouchEnd = () => {
|
|
if (isScrolling) {
|
|
flushScroll(true);
|
|
}
|
|
isScrolling = false;
|
|
};
|
|
|
|
container.addEventListener("touchstart", onTouchStart, {
|
|
passive: false,
|
|
capture: true,
|
|
});
|
|
container.addEventListener("touchmove", onTouchMove, {
|
|
passive: false,
|
|
capture: true,
|
|
});
|
|
container.addEventListener("touchend", onTouchEnd, {
|
|
capture: true,
|
|
});
|
|
touchCleanup = () => {
|
|
container.removeEventListener("touchstart", onTouchStart, {
|
|
capture: true,
|
|
});
|
|
container.removeEventListener("touchmove", onTouchMove, {
|
|
capture: true,
|
|
});
|
|
container.removeEventListener("touchend", onTouchEnd, {
|
|
capture: true,
|
|
});
|
|
};
|
|
}
|
|
|
|
// Initial fit after layout settles (terminal must be opened first)
|
|
let fitAttempts = 0;
|
|
const doInitialFit = () => {
|
|
if (!container.isConnected) return;
|
|
fitAttempts++;
|
|
// Ensure container has dimensions before fitting
|
|
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
|
fitTerminal();
|
|
} else if (fitAttempts < 50) {
|
|
// Container not ready yet, try again (max 50 attempts ~ 1s)
|
|
requestAnimationFrame(doInitialFit);
|
|
}
|
|
};
|
|
requestAnimationFrame(doInitialFit);
|
|
|
|
// Refit after font load (metrics may change)
|
|
document.fonts.ready.then(() => {
|
|
requestAnimationFrame(() => fitTerminal());
|
|
});
|
|
|
|
// Handle terminal input
|
|
term.onData((data) => {
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.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) {
|
|
currentWs.send(modified);
|
|
onModifierChange?.(null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
currentWs.send(data);
|
|
});
|
|
|
|
// Handle container resize with ResizeObserver for accurate dimension tracking
|
|
let resizeTimeout: ReturnType<typeof setTimeout>;
|
|
let lastWidth = 0;
|
|
let lastHeight = 0;
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (!entry) return;
|
|
|
|
const { width, height } = entry.contentRect;
|
|
// Only trigger if dimensions actually changed
|
|
if (width === lastWidth && height === lastHeight) return;
|
|
lastWidth = width;
|
|
lastHeight = height;
|
|
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
requestAnimationFrame(() => {
|
|
if (!container.isConnected) return;
|
|
fitTerminal();
|
|
});
|
|
}, 50);
|
|
});
|
|
resizeObserver.observe(container);
|
|
|
|
// Window resize fallback (for viewport changes that don't affect container dimensions)
|
|
let windowResizeTimeout: ReturnType<typeof setTimeout>;
|
|
const handleWindowResize = () => {
|
|
clearTimeout(windowResizeTimeout);
|
|
windowResizeTimeout = setTimeout(() => {
|
|
requestAnimationFrame(() => fitTerminal());
|
|
}, 250);
|
|
};
|
|
window.addEventListener("resize", handleWindowResize);
|
|
|
|
// Refit after mobile header auto-hides (3s delay + 0.3s transition)
|
|
const headerHideTimeout = setTimeout(() => {
|
|
fitTerminal();
|
|
}, 4000);
|
|
|
|
// Notify parent about terminal readiness
|
|
if (onTerminalReadyRef.current) {
|
|
const sendData = (data: string) => {
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
currentWs.send(data);
|
|
}
|
|
};
|
|
const focusInput = () => {
|
|
termRef.current?.focus();
|
|
};
|
|
const changeFontSize = (delta: number) => {
|
|
handleFontSizeChangeRef.current(delta);
|
|
};
|
|
onTerminalReadyRef.current(
|
|
sessionId,
|
|
sendData,
|
|
status,
|
|
focusInput,
|
|
changeFontSize,
|
|
);
|
|
}
|
|
|
|
// Visibility API for reconnection
|
|
const handleVisibilityChange = () => {
|
|
const currentWs = wsRef.current;
|
|
if (
|
|
document.visibilityState === "visible" &&
|
|
!permanentErrorRef.current &&
|
|
(currentWs === null || currentWs.readyState === WebSocket.CLOSED)
|
|
) {
|
|
reconnectAttemptsRef.current = 0;
|
|
connectWebSocket();
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
isUnmountingRef.current = true;
|
|
clearReconnectTimer();
|
|
clearTimeout(resizeTimeout);
|
|
clearTimeout(windowResizeTimeout);
|
|
clearTimeout(headerHideTimeout);
|
|
resizeObserver.disconnect();
|
|
window.removeEventListener("resize", handleWindowResize);
|
|
document.removeEventListener(
|
|
"visibilitychange",
|
|
handleVisibilityChange,
|
|
);
|
|
if (touchCleanup) touchCleanup();
|
|
container.removeEventListener("copy", handleBrowserCopy, true);
|
|
container.removeEventListener(
|
|
"contextmenu",
|
|
handleTerminalContextMenu,
|
|
true,
|
|
);
|
|
container.removeEventListener("paste", handleBrowserPaste, true);
|
|
pasteTextRef.current = () => {};
|
|
bracketedPasteEnabledRef.current = false;
|
|
const currentWs = wsRef.current;
|
|
wsRef.current = null;
|
|
if (currentWs) {
|
|
currentWs.close(1000, "Component unmounting");
|
|
}
|
|
if (heartbeatCheckRef.current) {
|
|
window.clearInterval(heartbeatCheckRef.current);
|
|
heartbeatCheckRef.current = null;
|
|
}
|
|
try {
|
|
term.dispose();
|
|
} catch {
|
|
// Ignore disposal errors from partially torn-down terminal
|
|
}
|
|
};
|
|
}, [instanceId, connectWebSocket, isMobile]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
fit: () => {
|
|
if (fitAddonRef.current && termRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
const { cols, rows } = termRef.current;
|
|
if (
|
|
wsRef.current?.readyState === WebSocket.OPEN &&
|
|
cols > 0 &&
|
|
rows > 0
|
|
) {
|
|
wsRef.current.send(
|
|
JSON.stringify({ type: "resize", cols, rows }),
|
|
);
|
|
}
|
|
} catch {
|
|
// Ignore fit errors
|
|
}
|
|
}
|
|
},
|
|
focus: () => {
|
|
termRef.current?.focus();
|
|
},
|
|
reset: () => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(JSON.stringify({ type: "reset" }));
|
|
}
|
|
},
|
|
}));
|
|
|
|
// 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) => {
|
|
handleFontSizeChangeRef.current(delta);
|
|
};
|
|
onTerminalReady(
|
|
sessionId,
|
|
sendData,
|
|
status,
|
|
focusInput,
|
|
changeFontSize,
|
|
);
|
|
}
|
|
}, [sessionId, 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
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
handleFontSizeChangeRef.current = handleFontSizeChange;
|
|
|
|
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 {
|
|
pasteTextRef.current(await navigator.clipboard.readText());
|
|
} catch {
|
|
// Clipboard API not available
|
|
}
|
|
};
|
|
|
|
// Focus terminal on mobile to keep keyboard open
|
|
const handleTerminalClick = () => {
|
|
setCopyMenu(null);
|
|
if (isMobile && termRef.current) {
|
|
termRef.current.focus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
|
|
>
|
|
{showControls && (
|
|
<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">
|
|
{status === "resetting"
|
|
? "Resetting..."
|
|
: 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>
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={() => setShowResetConfirm(true)}
|
|
type="button"
|
|
aria-label="Reset terminal"
|
|
>
|
|
Reset
|
|
</button>
|
|
{onClose && (
|
|
<button
|
|
className="terminal-close"
|
|
onClick={onClose}
|
|
type="button"
|
|
>
|
|
Close
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showResetConfirm && (
|
|
<div className="terminal-reset-confirm">
|
|
<div className="terminal-reset-confirm-content">
|
|
<p>
|
|
Reset terminal? This will kill the current shell session and
|
|
start fresh.
|
|
</p>
|
|
<div className="terminal-reset-confirm-buttons">
|
|
<button
|
|
className="terminal-reset-confirm-button cancel"
|
|
onClick={() => setShowResetConfirm(false)}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="terminal-reset-confirm-button confirm"
|
|
onClick={() => {
|
|
setShowResetConfirm(false);
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(JSON.stringify({ type: "reset" }));
|
|
}
|
|
}}
|
|
type="button"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{copyMenu && (
|
|
<button
|
|
aria-label="Copy selected terminal text"
|
|
className="terminal-context-copy"
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
copyTextWithFallback(copyMenu.text);
|
|
setCopyMenu(null);
|
|
}}
|
|
style={{
|
|
left: copyMenu.x,
|
|
position: "fixed",
|
|
top: copyMenu.y,
|
|
zIndex: 1000,
|
|
}}
|
|
type="button"
|
|
>
|
|
Copy
|
|
</button>
|
|
)}
|
|
{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>
|
|
);
|
|
},
|
|
);
|
|
|
|
TerminalComponent.displayName = "TerminalComponent";
|