mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
* fix(terminal): render emoji at their intended width The native grid scales glyphs to the columns assigned by the terminal buffer. Use grapheme widths across current hosts and clients so emoji fit their slots and replay preserves cursor positions. * fix(terminal): make width regression test portable * test(terminal): wait for replay fixture output to finish A trailing newline could arrive after the readiness marker and change the retained history between replay captures.
597 lines
16 KiB
TypeScript
597 lines
16 KiB
TypeScript
"use dom";
|
|
|
|
import type {
|
|
TerminalFindHandle,
|
|
TerminalFindResult,
|
|
} from "../terminal/runtime/terminal-emulator-runtime";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
type CSSProperties,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type Ref,
|
|
} from "react";
|
|
import type { DOMProps } from "expo/dom";
|
|
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import type { ITheme } from "@xterm/xterm";
|
|
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
|
import {
|
|
TerminalEmulatorRuntime,
|
|
type TerminalOutputData,
|
|
} from "../terminal/runtime/terminal-emulator-runtime";
|
|
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
|
|
import type { TerminalFile } from "../terminal/file-paste";
|
|
import type { TerminalCharacterWidths } from "../terminal/character-widths";
|
|
import type {
|
|
TerminalLocalFileLinkSource,
|
|
TerminalLocalFileLinkTarget,
|
|
} from "../terminal/local-links/terminal-local-link-provider";
|
|
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
|
|
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
|
import { openExternalUrl } from "../utils/open-external-url";
|
|
import { focusWithRetries } from "../utils/web-focus";
|
|
|
|
export interface TerminalEmulatorHandle {
|
|
find?: TerminalFindHandle;
|
|
writeOutput: (data: TerminalOutputData) => void;
|
|
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
|
|
paste: (text: string) => void;
|
|
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
|
|
clear: () => void;
|
|
claimSize: () => void;
|
|
showKeyboard: () => void;
|
|
blur: () => void;
|
|
}
|
|
|
|
const HOST_DIV_STYLE: CSSProperties = {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
minWidth: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
overflow: "hidden",
|
|
overscrollBehavior: "none",
|
|
paddingTop: 0,
|
|
paddingBottom: 0,
|
|
paddingLeft: 0,
|
|
paddingRight: 0,
|
|
};
|
|
|
|
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;
|
|
ref: Ref<TerminalEmulatorHandle>;
|
|
streamKey: string;
|
|
supportsTerminalInputModeReplay: boolean;
|
|
characterWidths: TerminalCharacterWidths;
|
|
testId?: string;
|
|
xtermTheme?: ITheme;
|
|
scrollbackLines: number;
|
|
fontFamily?: string;
|
|
fontSize?: number;
|
|
keyboardInset?: number;
|
|
isKeyboardVisible?: boolean;
|
|
swipeGesturesEnabled?: boolean;
|
|
onSwipeLeft?: () => void;
|
|
onSwipeRight?: () => void;
|
|
onFindRequest?: () => void;
|
|
onFindResult?: (result: TerminalFindResult) => void;
|
|
onInput?: (data: string) => Promise<void> | void;
|
|
onFocus?: () => Promise<void> | void;
|
|
onResize?: (input: {
|
|
rows: number;
|
|
cols: number;
|
|
shouldClaim: boolean;
|
|
forceClaim?: boolean;
|
|
}) => Promise<void> | void;
|
|
onTerminalKey?: (input: {
|
|
key: string;
|
|
ctrl: boolean;
|
|
shift: boolean;
|
|
alt: boolean;
|
|
meta: boolean;
|
|
}) => Promise<void> | void;
|
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
|
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
|
onSelectionChange?: (hasSelection: boolean) => void;
|
|
onResolveLocalFileLink?: (
|
|
source: TerminalLocalFileLinkSource,
|
|
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
|
|
onOpenLocalFileLink?: (
|
|
target: TerminalLocalFileLinkTarget,
|
|
disposition: "main" | "side",
|
|
) => Promise<void> | void;
|
|
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
|
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
|
|
pendingModifiers?: PendingTerminalModifiers;
|
|
focusRequestToken?: number;
|
|
resizeRequestToken?: number;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {}
|
|
}
|
|
|
|
export default function TerminalEmulator({
|
|
ref,
|
|
streamKey,
|
|
testId = "terminal-surface",
|
|
xtermTheme = {
|
|
background: "#0b0b0b",
|
|
foreground: "#e6e6e6",
|
|
cursor: "#e6e6e6",
|
|
},
|
|
scrollbackLines,
|
|
characterWidths,
|
|
fontFamily,
|
|
fontSize,
|
|
swipeGesturesEnabled = false,
|
|
onSwipeLeft,
|
|
onSwipeRight,
|
|
onFindRequest,
|
|
onFindResult,
|
|
onInput,
|
|
onFocus,
|
|
onResize,
|
|
onTerminalKey,
|
|
onPendingModifiersConsumed,
|
|
onInputModeChange,
|
|
onResolveLocalFileLink,
|
|
onOpenLocalFileLink,
|
|
onRendererReadyChange,
|
|
onFilePaste,
|
|
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 mountedThemeRef = useRef<ITheme>(xtermTheme);
|
|
const fontFamilyRef = useRef(fontFamily);
|
|
const fontSizeRef = useRef(fontSize);
|
|
const scrollbackLinesRef = useRef(scrollbackLines);
|
|
scrollbackLinesRef.current = scrollbackLines;
|
|
const characterWidthsRef = useRef(characterWidths);
|
|
characterWidthsRef.current = characterWidths;
|
|
fontFamilyRef.current = fontFamily;
|
|
fontSizeRef.current = fontSize;
|
|
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
|
const xtermThemeRef = useRef(xtermTheme);
|
|
xtermThemeRef.current = xtermTheme;
|
|
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
|
|
onRendererReadyChangeRef.current = onRendererReadyChange;
|
|
const mountCallbacksRef = useRef({
|
|
onFindRequest,
|
|
onFindResult,
|
|
onInput,
|
|
onResize,
|
|
onTerminalKey,
|
|
onPendingModifiersConsumed,
|
|
onInputModeChange,
|
|
onResolveLocalFileLink,
|
|
onOpenLocalFileLink,
|
|
onFilePaste,
|
|
});
|
|
mountCallbacksRef.current = {
|
|
onFindRequest,
|
|
onFindResult,
|
|
onInput,
|
|
onResize,
|
|
onTerminalKey,
|
|
onPendingModifiersConsumed,
|
|
onInputModeChange,
|
|
onResolveLocalFileLink,
|
|
onOpenLocalFileLink,
|
|
onFilePaste,
|
|
};
|
|
const pendingModifiersRef = useRef(pendingModifiers);
|
|
pendingModifiersRef.current = pendingModifiers;
|
|
|
|
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
|
const pasteText = useCallback((text: string) => {
|
|
if (text.length === 0) {
|
|
return;
|
|
}
|
|
mountCallbacksRef.current.onInput?.(
|
|
encodeTerminalPaste({
|
|
text,
|
|
bracketedPaste: runtimeRef.current?.getInputModeState().bracketedPaste ?? false,
|
|
}),
|
|
);
|
|
}, []);
|
|
|
|
useDOMImperativeHandle(
|
|
domBridgeRef,
|
|
(): DOMImperativeFactory => ({
|
|
writeOutput: (...args) => {
|
|
const data = args[0];
|
|
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
|
|
},
|
|
replay: (event) => {
|
|
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
|
|
if (event.type === "data" && event.data instanceof Uint8Array)
|
|
runtimeRef.current?.replay({ type: "data", data: event.data });
|
|
if (event.type === "begin")
|
|
runtimeRef.current?.replay({
|
|
type: "begin",
|
|
rows: typeof event.rows === "number" ? event.rows : undefined,
|
|
cols: typeof event.cols === "number" ? event.cols : undefined,
|
|
});
|
|
},
|
|
paste: (...args) => {
|
|
const text = args[0];
|
|
if (typeof text === "string" && text.length > 0) {
|
|
pasteText(text);
|
|
}
|
|
},
|
|
copySelection: async () => "",
|
|
clear: () => {
|
|
runtimeRef.current?.clear();
|
|
},
|
|
claimSize: () => {
|
|
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
|
|
},
|
|
showKeyboard: () => {
|
|
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
|
|
runtimeRef.current?.focus();
|
|
},
|
|
blur: () => {
|
|
runtimeRef.current?.blur();
|
|
},
|
|
}),
|
|
[pasteText],
|
|
);
|
|
useImperativeHandle(
|
|
ref,
|
|
(): TerminalEmulatorHandle => ({
|
|
find: {
|
|
setWidgetSize: (size) => runtimeRef.current?.find.setWidgetSize(size),
|
|
search: (query, direction) => runtimeRef.current?.find.search(query, direction),
|
|
clear: () => runtimeRef.current?.find.clear(),
|
|
},
|
|
writeOutput: (data: TerminalOutputData) => {
|
|
runtimeRef.current?.write({ data });
|
|
},
|
|
replay: (event) => runtimeRef.current?.replay(event),
|
|
paste: (text: string) => {
|
|
pasteText(text);
|
|
},
|
|
copySelection: async () => "",
|
|
clear: () => {
|
|
runtimeRef.current?.clear();
|
|
},
|
|
claimSize: () => {
|
|
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
|
|
},
|
|
showKeyboard: () => {
|
|
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
|
|
runtimeRef.current?.focus();
|
|
},
|
|
blur: () => {
|
|
runtimeRef.current?.blur();
|
|
},
|
|
}),
|
|
[pasteText],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const nextTheme = xtermThemeRef.current;
|
|
mountedThemeRef.current = nextTheme;
|
|
runtimeRef.current?.setTheme({ theme: nextTheme });
|
|
}, [themeKey]);
|
|
|
|
useEffect(() => {
|
|
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
|
|
}, [scrollbackLines]);
|
|
|
|
useEffect(() => {
|
|
runtimeRef.current?.setCharacterWidths({ widths: characterWidths });
|
|
}, [characterWidths]);
|
|
|
|
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: {
|
|
...mountCallbacksRef.current,
|
|
onOpenExternalUrl: openExternalUrl,
|
|
},
|
|
});
|
|
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
|
|
runtime.setCharacterWidths({ widths: characterWidthsRef.current });
|
|
runtime.mount({
|
|
root,
|
|
host,
|
|
scrollback: scrollbackLinesRef.current,
|
|
theme: mountedThemeRef.current,
|
|
fontFamily: fontFamilyRef.current,
|
|
fontSize: fontSizeRef.current,
|
|
});
|
|
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
|
|
|
return () => {
|
|
runtime.unmount();
|
|
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
|
|
if (runtimeRef.current === runtime) {
|
|
runtimeRef.current = null;
|
|
}
|
|
};
|
|
}, [streamKey]);
|
|
|
|
useEffect(() => {
|
|
runtimeRef.current?.setCallbacks({
|
|
callbacks: {
|
|
onFindRequest,
|
|
onFindResult,
|
|
onInput,
|
|
onResize,
|
|
onTerminalKey,
|
|
onPendingModifiersConsumed,
|
|
onInputModeChange,
|
|
onResolveLocalFileLink,
|
|
onOpenLocalFileLink,
|
|
onFilePaste,
|
|
onOpenExternalUrl: openExternalUrl,
|
|
},
|
|
});
|
|
}, [
|
|
onFindRequest,
|
|
onFilePaste,
|
|
onFindResult,
|
|
onInput,
|
|
onInputModeChange,
|
|
onOpenLocalFileLink,
|
|
onPendingModifiersConsumed,
|
|
onResolveLocalFileLink,
|
|
onResize,
|
|
onTerminalKey,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
|
}, [pendingModifiers]);
|
|
|
|
useEffect(() => {
|
|
runtimeRef.current?.setFont({ fontFamily, fontSize });
|
|
}, [fontFamily, fontSize]);
|
|
|
|
useEffect(() => {
|
|
if (focusRequestToken <= 0) {
|
|
return () => {};
|
|
}
|
|
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
|
|
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({ forceClaim: false, shouldClaim: false });
|
|
}, [resizeRequestToken]);
|
|
|
|
const showTerminalContextMenu = useCallback(() => {
|
|
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
|
|
if (typeof showContextMenu !== "function") {
|
|
return;
|
|
}
|
|
|
|
const hasSelection = Boolean(window.getSelection()?.toString());
|
|
void showContextMenu({
|
|
kind: "terminal",
|
|
hasSelection,
|
|
});
|
|
}, []);
|
|
|
|
const handleRootPointerDown = useCallback(() => {
|
|
onFocus?.();
|
|
runtimeRef.current?.focus();
|
|
}, [onFocus]);
|
|
|
|
const handleRootContextMenu = useCallback(
|
|
(event: ReactMouseEvent) => {
|
|
event.preventDefault();
|
|
showTerminalContextMenu();
|
|
},
|
|
[showTerminalContextMenu],
|
|
);
|
|
|
|
const rootDivStyle = useMemo<CSSProperties>(
|
|
() => ({
|
|
position: "relative",
|
|
display: "flex",
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: 0,
|
|
minWidth: 0,
|
|
backgroundColor: xtermTheme.background ?? "#0b0b0b",
|
|
overflow: "hidden",
|
|
overscrollBehavior: "none",
|
|
touchAction: "pan-y",
|
|
}),
|
|
[xtermTheme.background],
|
|
);
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
data-testid={testId}
|
|
data-terminal-scrollbar-root="true"
|
|
style={rootDivStyle}
|
|
onPointerDown={handleRootPointerDown}
|
|
onContextMenu={handleRootContextMenu}
|
|
>
|
|
<div ref={hostRef} style={HOST_DIV_STYLE} />
|
|
</div>
|
|
);
|
|
}
|