376 lines
9.3 KiB
TypeScript
376 lines
9.3 KiB
TypeScript
"use dom";
|
|
|
|
import { useEffect, useMemo, useRef } from "react";
|
|
import type { DOMProps } from "expo/dom";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import type { ITheme } from "@xterm/xterm";
|
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
|
import { focusWithRetries } from "../utils/web-focus";
|
|
|
|
function buildXtermThemeKey(theme: ITheme): string {
|
|
const values: Array<string> = [
|
|
theme.background,
|
|
theme.foreground,
|
|
theme.cursor,
|
|
theme.cursorAccent,
|
|
theme.selectionBackground,
|
|
theme.selectionForeground,
|
|
theme.black,
|
|
theme.red,
|
|
theme.green,
|
|
theme.yellow,
|
|
theme.blue,
|
|
theme.magenta,
|
|
theme.cyan,
|
|
theme.white,
|
|
theme.brightBlack,
|
|
theme.brightRed,
|
|
theme.brightGreen,
|
|
theme.brightYellow,
|
|
theme.brightBlue,
|
|
theme.brightMagenta,
|
|
theme.brightCyan,
|
|
theme.brightWhite,
|
|
].map((value) => (typeof value === "string" ? value : ""));
|
|
|
|
return values.join("|");
|
|
}
|
|
|
|
interface TerminalEmulatorProps {
|
|
dom?: DOMProps;
|
|
streamKey: string;
|
|
initialOutputText: string;
|
|
initialOutputChunkSequence?: number;
|
|
outputChunkText: string;
|
|
outputChunkSequence: number;
|
|
outputChunkReplay?: boolean;
|
|
testId?: string;
|
|
xtermTheme?: ITheme;
|
|
swipeGesturesEnabled?: boolean;
|
|
onSwipeLeft?: () => void;
|
|
onSwipeRight?: () => void;
|
|
onInput?: (data: string) => Promise<void> | void;
|
|
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
|
onTerminalKey?: (input: {
|
|
key: string;
|
|
ctrl: boolean;
|
|
shift: boolean;
|
|
alt: boolean;
|
|
meta: boolean;
|
|
}) => Promise<void> | void;
|
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
|
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
|
|
pendingModifiers?: PendingTerminalModifiers;
|
|
focusRequestToken?: number;
|
|
resizeRequestToken?: number;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {}
|
|
}
|
|
|
|
export default function TerminalEmulator({
|
|
streamKey,
|
|
initialOutputText,
|
|
initialOutputChunkSequence = 0,
|
|
outputChunkText,
|
|
outputChunkSequence,
|
|
outputChunkReplay = false,
|
|
testId = "terminal-surface",
|
|
xtermTheme = {
|
|
background: "#0b0b0b",
|
|
foreground: "#e6e6e6",
|
|
cursor: "#e6e6e6",
|
|
},
|
|
swipeGesturesEnabled = false,
|
|
onSwipeLeft,
|
|
onSwipeRight,
|
|
onInput,
|
|
onResize,
|
|
onTerminalKey,
|
|
onPendingModifiersConsumed,
|
|
onOutputChunkConsumed,
|
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
|
focusRequestToken = 0,
|
|
resizeRequestToken = 0,
|
|
}: TerminalEmulatorProps) {
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
|
const appliedChunkSequenceRef = useRef(0);
|
|
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
|
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
|
|
|
useEffect(() => {
|
|
mountedThemeRef.current = xtermTheme;
|
|
runtimeRef.current?.setTheme({ theme: xtermTheme });
|
|
}, [themeKey]);
|
|
|
|
useEffect(() => {
|
|
const root = rootRef.current;
|
|
if (!root || !swipeGesturesEnabled) {
|
|
return;
|
|
}
|
|
|
|
const SWIPE_MIN_PX = 22;
|
|
const VERTICAL_CANCEL_PX = 12;
|
|
const HORIZONTAL_DOMINANCE_RATIO = 1.2;
|
|
|
|
let tracking = false;
|
|
let activePointerId: number | null = null;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let fired = false;
|
|
|
|
const reset = () => {
|
|
tracking = false;
|
|
activePointerId = null;
|
|
startX = 0;
|
|
startY = 0;
|
|
fired = false;
|
|
};
|
|
|
|
const shouldTreatAsVertical = (dx: number, dy: number) => {
|
|
const absDx = Math.abs(dx);
|
|
const absDy = Math.abs(dy);
|
|
if (absDy < VERTICAL_CANCEL_PX) {
|
|
return false;
|
|
}
|
|
return absDy > absDx;
|
|
};
|
|
|
|
const shouldTreatAsHorizontal = (dx: number, dy: number) => {
|
|
const absDx = Math.abs(dx);
|
|
const absDy = Math.abs(dy);
|
|
if (absDx < SWIPE_MIN_PX) {
|
|
return false;
|
|
}
|
|
if (absDy === 0) {
|
|
return true;
|
|
}
|
|
return absDx / absDy >= HORIZONTAL_DOMINANCE_RATIO;
|
|
};
|
|
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
if (!event.isPrimary) {
|
|
return;
|
|
}
|
|
tracking = true;
|
|
fired = false;
|
|
activePointerId = event.pointerId;
|
|
startX = event.clientX;
|
|
startY = event.clientY;
|
|
};
|
|
|
|
const onPointerMove = (event: PointerEvent) => {
|
|
if (!tracking || fired) {
|
|
return;
|
|
}
|
|
if (activePointerId !== null && event.pointerId !== activePointerId) {
|
|
return;
|
|
}
|
|
|
|
const dx = event.clientX - startX;
|
|
const dy = event.clientY - startY;
|
|
|
|
if (shouldTreatAsVertical(dx, dy)) {
|
|
reset();
|
|
return;
|
|
}
|
|
|
|
if (!shouldTreatAsHorizontal(dx, dy)) {
|
|
return;
|
|
}
|
|
|
|
fired = true;
|
|
|
|
if (dx > 0) {
|
|
onSwipeRight?.();
|
|
} else {
|
|
onSwipeLeft?.();
|
|
}
|
|
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
const onPointerUp = (event: PointerEvent) => {
|
|
if (activePointerId !== null && event.pointerId !== activePointerId) {
|
|
return;
|
|
}
|
|
reset();
|
|
};
|
|
|
|
const onPointerCancel = (event: PointerEvent) => {
|
|
if (activePointerId !== null && event.pointerId !== activePointerId) {
|
|
return;
|
|
}
|
|
reset();
|
|
};
|
|
|
|
root.addEventListener("pointerdown", onPointerDown, { passive: true });
|
|
root.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
root.addEventListener("pointerup", onPointerUp, { passive: true });
|
|
root.addEventListener("pointercancel", onPointerCancel, { passive: true });
|
|
|
|
return () => {
|
|
root.removeEventListener("pointerdown", onPointerDown);
|
|
root.removeEventListener("pointermove", onPointerMove);
|
|
root.removeEventListener("pointerup", onPointerUp);
|
|
root.removeEventListener("pointercancel", onPointerCancel);
|
|
};
|
|
}, [onSwipeLeft, onSwipeRight, swipeGesturesEnabled]);
|
|
|
|
useEffect(() => {
|
|
const host = hostRef.current;
|
|
const root = rootRef.current;
|
|
if (!host || !root) {
|
|
return;
|
|
}
|
|
|
|
const runtime = new TerminalEmulatorRuntime();
|
|
runtimeRef.current = runtime;
|
|
runtime.setCallbacks({
|
|
callbacks: {
|
|
onInput,
|
|
onResize,
|
|
onTerminalKey,
|
|
onPendingModifiersConsumed,
|
|
},
|
|
});
|
|
runtime.setPendingModifiers({ pendingModifiers });
|
|
runtime.mount({
|
|
root,
|
|
host,
|
|
initialOutputText,
|
|
theme: mountedThemeRef.current,
|
|
});
|
|
appliedChunkSequenceRef.current = Math.max(
|
|
0,
|
|
Math.floor(initialOutputChunkSequence)
|
|
);
|
|
|
|
return () => {
|
|
runtime.unmount();
|
|
if (runtimeRef.current === runtime) {
|
|
runtimeRef.current = null;
|
|
}
|
|
appliedChunkSequenceRef.current = 0;
|
|
};
|
|
}, [streamKey]);
|
|
|
|
useEffect(() => {
|
|
runtimeRef.current?.setCallbacks({
|
|
callbacks: {
|
|
onInput,
|
|
onResize,
|
|
onTerminalKey,
|
|
onPendingModifiersConsumed,
|
|
},
|
|
});
|
|
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
|
|
|
useEffect(() => {
|
|
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
|
}, [pendingModifiers]);
|
|
|
|
useEffect(() => {
|
|
const runtime = runtimeRef.current;
|
|
if (outputChunkSequence <= 0) {
|
|
return;
|
|
}
|
|
|
|
if (outputChunkSequence <= appliedChunkSequenceRef.current) {
|
|
onOutputChunkConsumed?.(outputChunkSequence);
|
|
return;
|
|
}
|
|
|
|
if (!runtime) {
|
|
onOutputChunkConsumed?.(outputChunkSequence);
|
|
return;
|
|
}
|
|
|
|
appliedChunkSequenceRef.current = outputChunkSequence;
|
|
|
|
if (outputChunkText.length === 0) {
|
|
runtime.clear({
|
|
onCommitted: () => {
|
|
onOutputChunkConsumed?.(outputChunkSequence);
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
runtime.write({
|
|
text: outputChunkText,
|
|
suppressInput: outputChunkReplay,
|
|
onCommitted: () => {
|
|
onOutputChunkConsumed?.(outputChunkSequence);
|
|
},
|
|
});
|
|
}, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]);
|
|
|
|
useEffect(() => {
|
|
if (focusRequestToken <= 0) {
|
|
return;
|
|
}
|
|
return focusWithRetries({
|
|
focus: () => {
|
|
runtimeRef.current?.focus();
|
|
},
|
|
isFocused: () => {
|
|
const root = rootRef.current;
|
|
if (!root) {
|
|
return false;
|
|
}
|
|
const active = typeof document !== "undefined" ? document.activeElement : null;
|
|
return active instanceof HTMLElement && root.contains(active);
|
|
},
|
|
});
|
|
}, [focusRequestToken]);
|
|
|
|
useEffect(() => {
|
|
if (resizeRequestToken <= 0) {
|
|
return;
|
|
}
|
|
runtimeRef.current?.resize({ force: true });
|
|
}, [resizeRequestToken]);
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
data-testid={testId}
|
|
style={{
|
|
position: "relative",
|
|
display: "flex",
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: 0,
|
|
minWidth: 0,
|
|
backgroundColor: xtermTheme.background ?? "#0b0b0b",
|
|
overflow: "hidden",
|
|
overscrollBehavior: "none",
|
|
touchAction: "pan-y",
|
|
}}
|
|
onPointerDown={() => {
|
|
runtimeRef.current?.focus();
|
|
}}
|
|
>
|
|
<div
|
|
ref={hostRef}
|
|
style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
minWidth: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
overflow: "hidden",
|
|
overscrollBehavior: "none",
|
|
padding: 8,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|