Files
paseo/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts
T

392 lines
12 KiB
TypeScript

import type { ITheme } from "@xterm/xterm";
import xtermCss from "@xterm/xterm/css/xterm.css";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "@/utils/terminal-keys";
import {
encodeTerminalOutput,
TerminalEmulatorRuntime,
} from "../runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../local-links/terminal-local-link-provider";
interface MountMessage {
type: "mount";
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
type InboundMessage =
| MountMessage
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
| { type: "resize"; streamKey: string }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
type: "resolveLocalFileLinkResponse";
streamKey: string;
requestId: number;
target: TerminalLocalFileLinkTarget | null;
};
type OutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number }
| {
type: "terminalKey";
streamKey: string;
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
requestId: number;
source: TerminalLocalFileLinkSource;
}
| {
type: "openLocalFileLink";
streamKey: string;
target: TerminalLocalFileLinkTarget;
disposition: "main" | "side";
}
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
declare global {
interface Window {
ReactNativeWebView?: {
postMessage?: (data: string) => void;
};
__PASEO_TERMINAL_WEBVIEW_RECEIVE__?: (message: InboundMessage) => void;
}
}
const sendToNative = (message: OutboundMessage): void => {
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
};
const installStyles = (): void => {
const style = document.createElement("style");
style.textContent = `
${xtermCss}
html,
body,
#terminal-root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
background: #0b0b0b;
}
#terminal-root {
display: flex;
min-width: 0;
min-height: 0;
}
#terminal-host {
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
[data-terminal-scrollbar-root="true"] .xterm-viewport {
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
width: 0;
height: 0;
}
`;
document.head.appendChild(style);
};
class TerminalWebViewBridge {
private runtime: TerminalEmulatorRuntime | null = null;
private streamKey: string | null = null;
private nextLocalFileLinkRequestId = 1;
private readonly pendingLocalFileLinkResolutions = new Map<
number,
(target: TerminalLocalFileLinkTarget | null) => void
>();
private swipeGesturesEnabled = false;
private trackingSwipe = false;
private activePointerId: number | null = null;
private startX = 0;
private startY = 0;
private firedSwipe = false;
constructor(
private readonly root: HTMLDivElement,
private readonly host: HTMLDivElement,
) {
this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true });
this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false });
this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true });
this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true });
}
receive = (message: InboundMessage): void => {
try {
this.receiveUnsafe(message);
} catch (error) {
sendToNative({
type: "debug",
message: "terminal webview receive failed",
details: error instanceof Error ? { message: error.message, stack: error.stack } : error,
});
}
};
private receiveUnsafe(message: InboundMessage): void {
if (message.type === "mount") {
this.mount(message);
return;
}
if (message.type === "unmount") {
this.unmount(message.streamKey);
return;
}
if (!this.matches(message.streamKey)) {
return;
}
if (message.type === "resolveLocalFileLinkResponse") {
this.resolveLocalFileLinkRequest(message.requestId, message.target);
return;
}
this.receiveMounted(message);
}
private receiveMounted(
message: Exclude<
InboundMessage,
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
>,
): void {
switch (message.type) {
case "writeOutput":
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
break;
case "restoreOutput":
this.runtime?.restoreOutput({ data: encodeTerminalOutput(message.text) });
break;
case "renderSnapshot":
this.runtime?.renderSnapshot({ state: message.state });
break;
case "clear":
this.runtime?.clear();
break;
case "focus":
this.runtime?.focus();
break;
case "resize":
this.runtime?.resize({ force: true });
break;
case "setTheme":
this.runtime?.setTheme({ theme: message.theme });
break;
case "setScrollback":
this.runtime?.setScrollback({ lines: message.lines });
break;
case "setPendingModifiers":
this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
break;
case "setSwipeGesturesEnabled":
this.swipeGesturesEnabled = message.enabled;
break;
}
}
private mount(message: MountMessage): void {
this.unmount(this.streamKey);
this.streamKey = message.streamKey;
this.swipeGesturesEnabled = message.swipeGesturesEnabled;
document.body.style.backgroundColor = message.theme.background ?? "#0b0b0b";
const runtime = new TerminalEmulatorRuntime();
this.runtime = runtime;
runtime.setCallbacks({
callbacks: {
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
onResize: ({ rows, cols }) =>
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }),
onTerminalKey: (input) =>
sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }),
onPendingModifiersConsumed: () =>
sendToNative({ type: "pendingModifiersConsumed", streamKey: message.streamKey }),
onInputModeChange: (state) =>
sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }),
onOpenExternalUrl: (url) =>
sendToNative({ type: "openExternalUrl", streamKey: message.streamKey, url }),
onResolveLocalFileLink: (source) => this.requestLocalFileLinkResolution(source),
onOpenLocalFileLink: (target, disposition) =>
sendToNative({
type: "openLocalFileLink",
streamKey: message.streamKey,
target,
disposition,
}),
},
});
runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
runtime.mount({
root: this.root,
host: this.host,
initialSnapshot: message.initialSnapshot,
scrollback: message.scrollbackLines,
theme: message.theme,
});
sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
}
private unmount(streamKey: string | null): void {
if (!this.runtime) {
return;
}
const previousStreamKey = this.streamKey;
this.runtime.unmount();
this.runtime = null;
this.streamKey = null;
this.resolveAllLocalFileLinkRequests(null);
if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) {
sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false });
}
}
private matches(streamKey: string): boolean {
return this.streamKey === streamKey;
}
private requestLocalFileLinkResolution(
source: TerminalLocalFileLinkSource,
): Promise<TerminalLocalFileLinkTarget | null> {
const streamKey = this.streamKey;
if (!streamKey) {
return Promise.resolve(null);
}
const requestId = this.nextLocalFileLinkRequestId++;
return new Promise((resolve) => {
this.pendingLocalFileLinkResolutions.set(requestId, resolve);
sendToNative({
type: "resolveLocalFileLink",
streamKey,
requestId,
source,
});
});
}
private resolveLocalFileLinkRequest(
requestId: number,
target: TerminalLocalFileLinkTarget | null,
): void {
const resolve = this.pendingLocalFileLinkResolutions.get(requestId);
if (!resolve) {
return;
}
this.pendingLocalFileLinkResolutions.delete(requestId);
resolve(target);
}
private resolveAllLocalFileLinkRequests(target: TerminalLocalFileLinkTarget | null): void {
const requests = Array.from(this.pendingLocalFileLinkResolutions.values());
this.pendingLocalFileLinkResolutions.clear();
for (const resolve of requests) {
resolve(target);
}
}
private handlePointerDown = (event: PointerEvent): void => {
if (!this.swipeGesturesEnabled || !event.isPrimary) {
return;
}
this.trackingSwipe = true;
this.firedSwipe = false;
this.activePointerId = event.pointerId;
this.startX = event.clientX;
this.startY = event.clientY;
};
private handlePointerMove = (event: PointerEvent): void => {
if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) {
return;
}
if (this.activePointerId !== null && event.pointerId !== this.activePointerId) {
return;
}
const dx = event.clientX - this.startX;
const dy = event.clientY - this.startY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDy >= 12 && absDy > absDx) {
this.resetSwipe();
return;
}
if (absDx < 22 || (absDy !== 0 && absDx / absDy < 1.2)) {
return;
}
this.firedSwipe = true;
sendToNative({ type: dx > 0 ? "swipeRight" : "swipeLeft", streamKey: this.streamKey });
if (event.cancelable) event.preventDefault();
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.activePointerId !== null && event.pointerId !== this.activePointerId) {
return;
}
this.resetSwipe();
};
private resetSwipe(): void {
this.trackingSwipe = false;
this.activePointerId = null;
this.startX = 0;
this.startY = 0;
this.firedSwipe = false;
}
}
installStyles();
const root = document.createElement("div");
root.id = "terminal-root";
root.dataset.terminalScrollbarRoot = "true";
const host = document.createElement("div");
host.id = "terminal-host";
root.appendChild(host);
document.body.appendChild(root);
const bridge = new TerminalWebViewBridge(root, host);
window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ = bridge.receive;
sendToNative({ type: "bridgeReady" });