refactor(app): extract terminal runtime out of react lifecycle
This commit is contained in:
@@ -1,29 +1,23 @@
|
||||
"use dom";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import type { DOMProps } from "expo/dom";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import {
|
||||
type PendingTerminalModifiers,
|
||||
isTerminalModifierDomKey,
|
||||
mergeTerminalModifiers,
|
||||
normalizeDomTerminalKey,
|
||||
normalizeTerminalTransportKey,
|
||||
shouldInterceptDomTerminalKey,
|
||||
} from "../utils/terminal-keys";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
|
||||
interface TerminalEmulatorProps {
|
||||
dom?: DOMProps;
|
||||
streamKey: string;
|
||||
outputText: string;
|
||||
initialOutputText: string;
|
||||
outputChunkText: string;
|
||||
outputChunkSequence: number;
|
||||
testId?: string;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
cursorColor?: string;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onResize?: (rows: number, cols: number) => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
@@ -37,14 +31,14 @@ interface TerminalEmulatorProps {
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__paseoTerminal?: Terminal;
|
||||
}
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
export default function TerminalEmulator({
|
||||
streamKey,
|
||||
outputText,
|
||||
initialOutputText,
|
||||
outputChunkText,
|
||||
outputChunkSequence,
|
||||
testId = "terminal-surface",
|
||||
backgroundColor = "#0b0b0b",
|
||||
foregroundColor = "#e6e6e6",
|
||||
@@ -58,36 +52,7 @@ export default function TerminalEmulator({
|
||||
}: TerminalEmulatorProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const renderedOutputRef = useRef("");
|
||||
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
|
||||
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
|
||||
const onTerminalKeyRef = useRef<TerminalEmulatorProps["onTerminalKey"]>(onTerminalKey);
|
||||
const onPendingModifiersConsumedRef = useRef<
|
||||
TerminalEmulatorProps["onPendingModifiersConsumed"]
|
||||
>(onPendingModifiersConsumed);
|
||||
const pendingModifiersRef = useRef<PendingTerminalModifiers>(pendingModifiers);
|
||||
|
||||
useEffect(() => {
|
||||
onInputRef.current = onInput;
|
||||
}, [onInput]);
|
||||
|
||||
useEffect(() => {
|
||||
onResizeRef.current = onResize;
|
||||
}, [onResize]);
|
||||
|
||||
useEffect(() => {
|
||||
onTerminalKeyRef.current = onTerminalKey;
|
||||
}, [onTerminalKey]);
|
||||
|
||||
useEffect(() => {
|
||||
onPendingModifiersConsumedRef.current = onPendingModifiersConsumed;
|
||||
}, [onPendingModifiersConsumed]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingModifiersRef.current = pendingModifiers;
|
||||
}, [pendingModifiers]);
|
||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -96,405 +61,72 @@ export default function TerminalEmulator({
|
||||
return;
|
||||
}
|
||||
|
||||
renderedOutputRef.current = "";
|
||||
lastSizeRef.current = null;
|
||||
host.innerHTML = "";
|
||||
|
||||
const terminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.25,
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: backgroundColor,
|
||||
foreground: foregroundColor,
|
||||
cursor: cursorColor,
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtimeRef.current = runtime;
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
},
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(host);
|
||||
|
||||
const documentElement = document.documentElement;
|
||||
const body = document.body;
|
||||
const rootContainer = root.parentElement;
|
||||
|
||||
const previousDocumentElementOverflow = documentElement.style.overflow;
|
||||
const previousDocumentElementWidth = documentElement.style.width;
|
||||
const previousDocumentElementHeight = documentElement.style.height;
|
||||
|
||||
const previousBodyOverflow = body.style.overflow;
|
||||
const previousBodyWidth = body.style.width;
|
||||
const previousBodyHeight = body.style.height;
|
||||
const previousBodyMargin = body.style.margin;
|
||||
const previousBodyPadding = body.style.padding;
|
||||
|
||||
const previousRootOverflow = rootContainer?.style.overflow ?? "";
|
||||
const previousRootWidth = rootContainer?.style.width ?? "";
|
||||
const previousRootHeight = rootContainer?.style.height ?? "";
|
||||
|
||||
// Force document to follow WebView bounds; xterm viewport owns scrollback.
|
||||
documentElement.style.overflow = "hidden";
|
||||
documentElement.style.width = "100%";
|
||||
documentElement.style.height = "100%";
|
||||
|
||||
body.style.overflow = "hidden";
|
||||
body.style.width = "100%";
|
||||
body.style.height = "100%";
|
||||
body.style.margin = "0";
|
||||
body.style.padding = "0";
|
||||
|
||||
if (rootContainer) {
|
||||
rootContainer.style.overflow = "hidden";
|
||||
rootContainer.style.width = "100%";
|
||||
rootContainer.style.height = "100%";
|
||||
}
|
||||
|
||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
const screenElement = host.querySelector<HTMLElement>(".xterm-screen");
|
||||
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
|
||||
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
|
||||
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
|
||||
const previousViewportOverflowX = viewportElement?.style.overflowX ?? "";
|
||||
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
|
||||
const previousViewportWebkitOverflowScrolling =
|
||||
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
|
||||
if (viewportElement) {
|
||||
viewportElement.style.overscrollBehavior = "none";
|
||||
viewportElement.style.touchAction = "pan-y";
|
||||
viewportElement.style.overflowY = "auto";
|
||||
viewportElement.style.overflowX = "hidden";
|
||||
viewportElement.style.pointerEvents = "auto";
|
||||
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
|
||||
}
|
||||
const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? "";
|
||||
if (screenElement) {
|
||||
// xterm renders the screen layer above the viewport. Disable hit-testing on that layer
|
||||
// so touch drags can reach the scrollable viewport on mobile.
|
||||
screenElement.style.pointerEvents = "none";
|
||||
}
|
||||
|
||||
terminalRef.current = terminal;
|
||||
window.__paseoTerminal = terminal;
|
||||
|
||||
const fitAndEmitResize = (force = false) => {
|
||||
const handler = onResizeRef.current;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = terminal.rows;
|
||||
const cols = terminal.cols;
|
||||
const previous = lastSizeRef.current;
|
||||
if (!force && previous && previous.rows === rows && previous.cols === cols) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSizeRef.current = { rows, cols };
|
||||
void handler(rows, cols);
|
||||
};
|
||||
|
||||
fitAndEmitResize(true);
|
||||
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
const handler = onInputRef.current;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
void handler(data);
|
||||
runtime.setPendingModifiers({ pendingModifiers });
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialOutputText,
|
||||
theme: {
|
||||
backgroundColor,
|
||||
foregroundColor,
|
||||
cursorColor,
|
||||
},
|
||||
});
|
||||
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== "keydown" || event.isComposing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeDomTerminalKey(event.key);
|
||||
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pending = pendingModifiersRef.current;
|
||||
if (
|
||||
!shouldInterceptDomTerminalKey({
|
||||
key: normalizedKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
altKey: event.altKey,
|
||||
pendingModifiers: pending,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const modifiers = mergeTerminalModifiers({
|
||||
pendingModifiers: pending,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
});
|
||||
|
||||
const keyPayload = {
|
||||
key: normalizeTerminalTransportKey(normalizedKey),
|
||||
...modifiers,
|
||||
};
|
||||
onTerminalKeyRef.current?.(keyPayload);
|
||||
|
||||
if (pending.ctrl || pending.shift || pending.alt) {
|
||||
onPendingModifiersConsumedRef.current?.();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
let touchScrollRemainderPx = 0;
|
||||
const touchScrollLineHeightPx = (() => {
|
||||
const row = host.querySelector<HTMLElement>(".xterm-rows > div");
|
||||
const measured = row?.getBoundingClientRect().height;
|
||||
return measured && measured > 0 ? measured : 18;
|
||||
})();
|
||||
const activeTouchRef: {
|
||||
identifier: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
mode: "vertical" | "horizontal" | null;
|
||||
} = {
|
||||
identifier: -1,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
mode: null,
|
||||
};
|
||||
const rootTouchStartHandler = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
touchScrollRemainderPx = 0;
|
||||
activeTouchRef.identifier = -1;
|
||||
activeTouchRef.mode = null;
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
if (!touch) {
|
||||
touchScrollRemainderPx = 0;
|
||||
activeTouchRef.identifier = -1;
|
||||
activeTouchRef.mode = null;
|
||||
return;
|
||||
}
|
||||
activeTouchRef.identifier = touch.identifier;
|
||||
activeTouchRef.startX = touch.clientX;
|
||||
activeTouchRef.startY = touch.clientY;
|
||||
activeTouchRef.lastX = touch.clientX;
|
||||
activeTouchRef.lastY = touch.clientY;
|
||||
activeTouchRef.mode = null;
|
||||
touchScrollRemainderPx = 0;
|
||||
};
|
||||
const rootTouchMoveHandler = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const touch = Array.from(event.touches).find(
|
||||
(candidate) => candidate.identifier === activeTouchRef.identifier
|
||||
);
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDeltaX = touch.clientX - activeTouchRef.startX;
|
||||
const totalDeltaY = touch.clientY - activeTouchRef.startY;
|
||||
if (activeTouchRef.mode === null) {
|
||||
const absX = Math.abs(totalDeltaX);
|
||||
const absY = Math.abs(totalDeltaY);
|
||||
if (absX > 8 || absY > 8) {
|
||||
activeTouchRef.mode = absY >= absX ? "vertical" : "horizontal";
|
||||
}
|
||||
}
|
||||
|
||||
const deltaY = touch.clientY - activeTouchRef.lastY;
|
||||
activeTouchRef.lastX = touch.clientX;
|
||||
activeTouchRef.lastY = touch.clientY;
|
||||
|
||||
if (activeTouchRef.mode !== "vertical") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Manual vertical touch scrolling fallback for xterm's layered DOM.
|
||||
touchScrollRemainderPx += deltaY;
|
||||
const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx);
|
||||
if (lineDelta !== 0) {
|
||||
const appliedLineDelta = -lineDelta;
|
||||
terminal.scrollLines(appliedLineDelta);
|
||||
touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx;
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
const rootTouchEndHandler = (event: TouchEvent) => {
|
||||
const changed = Array.from(event.changedTouches).some(
|
||||
(touch) => touch.identifier === activeTouchRef.identifier
|
||||
);
|
||||
if (changed || event.touches.length === 0) {
|
||||
touchScrollRemainderPx = 0;
|
||||
activeTouchRef.identifier = -1;
|
||||
activeTouchRef.mode = null;
|
||||
}
|
||||
};
|
||||
const rootTouchCancelHandler = () => {
|
||||
touchScrollRemainderPx = 0;
|
||||
activeTouchRef.identifier = -1;
|
||||
activeTouchRef.mode = null;
|
||||
};
|
||||
root.addEventListener("touchstart", rootTouchStartHandler, { passive: true });
|
||||
root.addEventListener("touchmove", rootTouchMoveHandler, { passive: false });
|
||||
root.addEventListener("touchend", rootTouchEndHandler, { passive: true });
|
||||
root.addEventListener("touchcancel", rootTouchCancelHandler, { passive: true });
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAndEmitResize();
|
||||
});
|
||||
resizeObserver.observe(root);
|
||||
resizeObserver.observe(host);
|
||||
|
||||
const windowResizeHandler = () => fitAndEmitResize();
|
||||
window.addEventListener("resize", windowResizeHandler);
|
||||
|
||||
const visualViewport = window.visualViewport;
|
||||
const visualViewportResizeHandler = () => fitAndEmitResize();
|
||||
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
|
||||
|
||||
// Safety net for keyboard/layout transitions that can skip callbacks.
|
||||
const fitInterval = window.setInterval(() => {
|
||||
fitAndEmitResize();
|
||||
}, 250);
|
||||
|
||||
const fitTimeoutHandles = [0, 16, 48, 120, 250, 500, 1_000, 2_000].map((delay) =>
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
}, delay)
|
||||
);
|
||||
|
||||
const fontSet = document.fonts;
|
||||
const fontReadyHandler = () => {
|
||||
fitAndEmitResize(true);
|
||||
};
|
||||
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
|
||||
void fontSet?.ready
|
||||
.then(() => {
|
||||
fitAndEmitResize(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// no-op
|
||||
});
|
||||
|
||||
window.setTimeout(() => fitAndEmitResize(true), 0);
|
||||
|
||||
if (outputText.length > 0) {
|
||||
terminal.write(outputText);
|
||||
renderedOutputRef.current = outputText;
|
||||
}
|
||||
|
||||
return () => {
|
||||
inputDisposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", windowResizeHandler);
|
||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||
window.clearInterval(fitInterval);
|
||||
for (const handle of fitTimeoutHandles) {
|
||||
window.clearTimeout(handle);
|
||||
runtime.unmount();
|
||||
if (runtimeRef.current === runtime) {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
|
||||
root.removeEventListener("touchstart", rootTouchStartHandler);
|
||||
root.removeEventListener("touchmove", rootTouchMoveHandler);
|
||||
root.removeEventListener("touchend", rootTouchEndHandler);
|
||||
root.removeEventListener("touchcancel", rootTouchCancelHandler);
|
||||
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
|
||||
documentElement.style.overflow = previousDocumentElementOverflow;
|
||||
documentElement.style.width = previousDocumentElementWidth;
|
||||
documentElement.style.height = previousDocumentElementHeight;
|
||||
|
||||
body.style.overflow = previousBodyOverflow;
|
||||
body.style.width = previousBodyWidth;
|
||||
body.style.height = previousBodyHeight;
|
||||
body.style.margin = previousBodyMargin;
|
||||
body.style.padding = previousBodyPadding;
|
||||
|
||||
if (rootContainer) {
|
||||
rootContainer.style.overflow = previousRootOverflow;
|
||||
rootContainer.style.width = previousRootWidth;
|
||||
rootContainer.style.height = previousRootHeight;
|
||||
}
|
||||
|
||||
if (viewportElement) {
|
||||
viewportElement.style.overscrollBehavior = previousViewportOverscroll;
|
||||
viewportElement.style.touchAction = previousViewportTouchAction;
|
||||
viewportElement.style.overflowY = previousViewportOverflowY;
|
||||
viewportElement.style.overflowX = previousViewportOverflowX;
|
||||
viewportElement.style.pointerEvents = previousViewportPointerEvents;
|
||||
viewportElement.style.setProperty(
|
||||
"-webkit-overflow-scrolling",
|
||||
previousViewportWebkitOverflowScrolling
|
||||
);
|
||||
}
|
||||
if (screenElement) {
|
||||
screenElement.style.pointerEvents = previousScreenPointerEvents;
|
||||
}
|
||||
|
||||
terminalRef.current = null;
|
||||
if (window.__paseoTerminal === terminal) {
|
||||
window.__paseoTerminal = undefined;
|
||||
}
|
||||
renderedOutputRef.current = "";
|
||||
lastSizeRef.current = null;
|
||||
};
|
||||
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) {
|
||||
runtimeRef.current?.setCallbacks({
|
||||
callbacks: {
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
},
|
||||
});
|
||||
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||
}, [pendingModifiers]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = renderedOutputRef.current;
|
||||
if (outputText === previous) {
|
||||
if (outputChunkSequence <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous.length > 0 && outputText.startsWith(previous)) {
|
||||
const suffix = outputText.slice(previous.length);
|
||||
if (suffix.length > 0) {
|
||||
terminal.write(suffix);
|
||||
}
|
||||
} else {
|
||||
terminal.reset();
|
||||
terminal.clear();
|
||||
if (outputText.length > 0) {
|
||||
terminal.write(outputText);
|
||||
}
|
||||
if (outputChunkText.length === 0) {
|
||||
runtime.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
renderedOutputRef.current = outputText;
|
||||
}, [outputText]);
|
||||
runtime.write({ text: outputChunkText });
|
||||
}, [outputChunkSequence, outputChunkText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
return;
|
||||
}
|
||||
terminalRef.current?.focus();
|
||||
runtimeRef.current?.focus();
|
||||
}, [focusRequestToken]);
|
||||
|
||||
return (
|
||||
@@ -513,7 +145,7 @@ export default function TerminalEmulator({
|
||||
overscrollBehavior: "none",
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
terminalRef.current?.focus();
|
||||
runtimeRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -22,13 +22,13 @@ import {
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import {
|
||||
getTerminalResumeOffset,
|
||||
getTerminalAttachRetryDelayMs,
|
||||
isTerminalAttachRetryableError,
|
||||
updateTerminalResumeOffset,
|
||||
waitForDuration,
|
||||
withPromiseTimeout,
|
||||
} from "@/utils/terminal-attach";
|
||||
TerminalOutputPump,
|
||||
type TerminalOutputChunk,
|
||||
} from "@/terminal/runtime/terminal-output-pump";
|
||||
import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "@/terminal/runtime/terminal-stream-controller";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
@@ -38,8 +38,6 @@ interface TerminalPaneProps {
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||
const TERMINAL_ATTACH_MAX_ATTEMPTS = 4;
|
||||
const TERMINAL_ATTACH_TIMEOUT_MS = 12_000;
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -65,6 +63,11 @@ type ModifierState = {
|
||||
alt: boolean;
|
||||
};
|
||||
|
||||
type TerminalOutputChunkState = {
|
||||
sequence: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const EMPTY_MODIFIERS: ModifierState = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
@@ -112,17 +115,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const resumeOffsetByTerminalIdRef = useRef<Map<string, number>>(new Map());
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
||||
|
||||
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
|
||||
() => new Map()
|
||||
);
|
||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||
sequence: 0,
|
||||
text: "",
|
||||
});
|
||||
const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState("");
|
||||
const [activeStream, setActiveStream] = useState<{
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
} | null>(null);
|
||||
const [attachGeneration, setAttachGeneration] = useState(0);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
@@ -133,18 +138,27 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
);
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||
const activeStreamRef = useRef<{
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
} | null>(activeStream);
|
||||
|
||||
useEffect(() => {
|
||||
selectedTerminalIdRef.current = selectedTerminalId;
|
||||
}, [selectedTerminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
activeStreamRef.current = activeStream;
|
||||
}, [activeStream]);
|
||||
const outputPump = new TerminalOutputPump({
|
||||
maxOutputChars: MAX_OUTPUT_CHARS,
|
||||
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
|
||||
setSelectedOutputChunk(chunk);
|
||||
},
|
||||
});
|
||||
outputPumpRef.current = outputPump;
|
||||
|
||||
return () => {
|
||||
if (outputPumpRef.current === outputPump) {
|
||||
outputPumpRef.current = null;
|
||||
}
|
||||
outputPump.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearHoverOutTimeout = useCallback(() => {
|
||||
if (!hoverOutTimeoutRef.current) {
|
||||
@@ -221,20 +235,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeStreamForTerminal = activeStreamRef.current;
|
||||
if (
|
||||
selectedTerminalIdRef.current === exitedTerminalId &&
|
||||
activeStreamForTerminal?.terminalId === exitedTerminalId &&
|
||||
activeStreamForTerminal.streamId === message.payload.streamId
|
||||
) {
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === exitedTerminalId ? null : current
|
||||
);
|
||||
setStreamError("Terminal stream ended. Reconnecting…");
|
||||
setIsAttaching(true);
|
||||
setAttachGeneration((current) => current + 1);
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
streamControllerRef.current?.handleStreamExit({
|
||||
terminalId: exitedTerminalId,
|
||||
streamId: message.payload.streamId,
|
||||
});
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
@@ -277,15 +282,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
},
|
||||
onSuccess: (_, terminalId) => {
|
||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||
resumeOffsetByTerminalIdRef.current.delete(terminalId);
|
||||
outputPumpRef.current?.clearTerminal({ terminalId });
|
||||
if (selectedTerminalIdRef.current === terminalId) {
|
||||
setSelectedTerminalId((current) =>
|
||||
current === terminalId ? null : current
|
||||
);
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === terminalId ? null : current
|
||||
);
|
||||
setIsAttaching(false);
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
@@ -336,178 +337,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}, [scopeKey, terminals, selectedTerminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminals.length === 0) {
|
||||
resumeOffsetByTerminalIdRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalIdSet = new Set(terminals.map((terminal) => terminal.id));
|
||||
for (const terminalId of Array.from(resumeOffsetByTerminalIdRef.current.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
resumeOffsetByTerminalIdRef.current.delete(terminalId);
|
||||
}
|
||||
}
|
||||
const terminalIds = terminals.map((terminal) => terminal.id);
|
||||
outputPumpRef.current?.prune({ terminalIds });
|
||||
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
|
||||
}, [terminals]);
|
||||
|
||||
const appendOutput = useCallback((terminalId: string, text: string) => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
setOutputByTerminalId((previous) => {
|
||||
const next = new Map(previous);
|
||||
const existing = next.get(terminalId) ?? "";
|
||||
const combined = `${existing}${text}`;
|
||||
next.set(
|
||||
terminalId,
|
||||
combined.length > MAX_OUTPUT_CHARS
|
||||
? combined.slice(combined.length - MAX_OUTPUT_CHARS)
|
||||
: combined
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const handleStreamControllerStatus = useCallback(
|
||||
(status: TerminalStreamControllerStatus) => {
|
||||
setIsAttaching(status.isAttaching);
|
||||
setStreamError(status.error);
|
||||
if (status.terminalId && typeof status.streamId === "number") {
|
||||
setActiveStream({
|
||||
terminalId: status.terminalId,
|
||||
streamId: status.streamId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setActiveStream(null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
let streamId: number | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let decoder: TextDecoder | null = null;
|
||||
const terminalId = selectedTerminalId;
|
||||
streamControllerRef.current?.dispose();
|
||||
streamControllerRef.current = null;
|
||||
setActiveStream(null);
|
||||
setIsAttaching(false);
|
||||
setStreamError(null);
|
||||
|
||||
if (!client || !isConnected || !terminalId) {
|
||||
setActiveStream(null);
|
||||
setIsAttaching(false);
|
||||
if (!client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAttaching(true);
|
||||
setStreamError(null);
|
||||
const outputPump = outputPumpRef.current;
|
||||
if (!outputPump) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attach = async () => {
|
||||
try {
|
||||
let lastErrorMessage = "Unable to attach terminal stream";
|
||||
const totalAttempts = TERMINAL_ATTACH_MAX_ATTEMPTS;
|
||||
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
||||
try {
|
||||
const lastSize = lastReportedSizeRef.current;
|
||||
const resumeOffset = getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
const attachPayload = await withPromiseTimeout({
|
||||
promise: client.attachTerminalStream(terminalId, {
|
||||
...(resumeOffset !== undefined ? { resumeOffset } : {}),
|
||||
...(lastSize
|
||||
? {
|
||||
rows: lastSize.rows,
|
||||
cols: lastSize.cols,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
timeoutMs: TERMINAL_ATTACH_TIMEOUT_MS,
|
||||
timeoutMessage: "Timed out attaching terminal stream",
|
||||
});
|
||||
if (isCancelled) {
|
||||
if (typeof attachPayload.streamId === "number") {
|
||||
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
|
||||
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
|
||||
const hasRemainingAttempts = attempt < totalAttempts - 1;
|
||||
if (
|
||||
hasRemainingAttempts &&
|
||||
isTerminalAttachRetryableError({ message: lastErrorMessage })
|
||||
) {
|
||||
await waitForDuration({
|
||||
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
streamId = attachPayload.streamId;
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: attachPayload.currentOffset,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
decoder = new TextDecoder();
|
||||
setActiveStream({ terminalId, streamId });
|
||||
setStreamError(null);
|
||||
|
||||
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: chunk.endOffset,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
|
||||
appendOutput(terminalId, text);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastErrorMessage =
|
||||
error instanceof Error ? error.message : "Unable to attach terminal stream";
|
||||
const hasRemainingAttempts = attempt < totalAttempts - 1;
|
||||
if (
|
||||
hasRemainingAttempts &&
|
||||
isTerminalAttachRetryableError({ message: lastErrorMessage })
|
||||
) {
|
||||
await waitForDuration({
|
||||
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => lastReportedSizeRef.current,
|
||||
onChunk: ({ terminalId, text }) => {
|
||||
outputPump.append({ terminalId, text });
|
||||
},
|
||||
onReset: ({ terminalId }) => {
|
||||
outputPump.clearTerminal({ terminalId });
|
||||
if (selectedTerminalIdRef.current === terminalId) {
|
||||
setSelectedOutputSnapshot("");
|
||||
}
|
||||
},
|
||||
onStatusChange: handleStreamControllerStatus,
|
||||
});
|
||||
|
||||
if (!isCancelled) {
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsAttaching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void attach();
|
||||
streamControllerRef.current = controller;
|
||||
controller.setTerminal({ terminalId: selectedTerminalIdRef.current });
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (decoder) {
|
||||
appendOutput(terminalId, decoder.decode());
|
||||
decoder = null;
|
||||
controller.dispose();
|
||||
if (streamControllerRef.current === controller) {
|
||||
streamControllerRef.current = null;
|
||||
}
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
if (streamId !== null) {
|
||||
void client.detachTerminalStream(streamId).catch(() => {});
|
||||
}
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === terminalId ? null : current
|
||||
);
|
||||
setIsAttaching(false);
|
||||
};
|
||||
}, [appendOutput, attachGeneration, client, isConnected, selectedTerminalId]);
|
||||
}, [client, handleStreamControllerStatus, isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
outputPumpRef.current?.setSelectedTerminal({
|
||||
terminalId: selectedTerminalId,
|
||||
});
|
||||
streamControllerRef.current?.setTerminal({
|
||||
terminalId: selectedTerminalId,
|
||||
});
|
||||
setSelectedOutputSnapshot(
|
||||
outputPumpRef.current?.readSnapshot({
|
||||
terminalId: selectedTerminalId,
|
||||
}) ?? ""
|
||||
);
|
||||
}, [selectedTerminalId]);
|
||||
|
||||
const activeStreamId =
|
||||
activeStream && activeStream.terminalId === selectedTerminalId
|
||||
@@ -518,11 +423,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
||||
[terminals, selectedTerminalId]
|
||||
);
|
||||
|
||||
const currentOutput = selectedTerminalId
|
||||
? (outputByTerminalId.get(selectedTerminalId) ?? "")
|
||||
: "";
|
||||
|
||||
const handleCreateTerminal = useCallback(() => {
|
||||
createTerminalMutation.mutate();
|
||||
}, [createTerminalMutation]);
|
||||
@@ -623,7 +523,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
);
|
||||
|
||||
const handleTerminalResize = useCallback(
|
||||
async (rows: number, cols: number) => {
|
||||
async (input: { rows: number; cols: number }) => {
|
||||
const { rows, cols } = input;
|
||||
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -834,8 +735,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
automaticallyAdjustContentInsets: false,
|
||||
contentInsetAdjustmentBehavior: "never",
|
||||
}}
|
||||
streamKey={`${scopeKey}:${selectedTerminal.id}:${activeStreamId ?? "none"}`}
|
||||
outputText={currentOutput}
|
||||
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
||||
initialOutputText={selectedOutputSnapshot}
|
||||
outputChunkText={selectedOutputChunk.text}
|
||||
outputChunkSequence={selectedOutputChunk.sequence}
|
||||
testId="terminal-surface"
|
||||
backgroundColor={theme.colors.background}
|
||||
foregroundColor={theme.colors.foreground}
|
||||
|
||||
Reference in New Issue
Block a user