mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Remove the WebView terminal renderer: xterm on web, native grid on mobile (#7639)
* refactor(terminal): remove the WebView terminal renderer Native uses the native grid renderer only; web keeps xterm. The legacy renderer setting, its diagnostics toggle, the esbuild WebView bundle and its CI drift check are gone. * test(terminal): target the visible terminal surface in the file paste spec A second page opening a terminal in the same workspace adds a retained, hidden surface to the first page, so the unscoped locator matched two textareas and failed strict mode. * test(server): wait for the shortstat warm instead of polling 500ms The background warm shells out to git; on Windows CI that regularly outlasts the 20x25ms poll and fails the test. Use the completion callback the warm already exposes and keep the non-blocking check.
This commit is contained in:
@@ -415,9 +415,7 @@ jobs:
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Run app unit tests
|
||||
run: |
|
||||
npm run check:terminal-webview --workspace=@getpaseo/app
|
||||
npm run test --workspace=@getpaseo/app
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
|
||||
@@ -73,8 +73,14 @@ function fileRead(bytes: Buffer, location: "upload" | "local"): string {
|
||||
return `FILE_READ:${digest}:${location}`;
|
||||
}
|
||||
|
||||
// Inactive terminal tabs retain their surfaces, and another page opening a terminal in
|
||||
// the same workspace adds a tab here too. Only the selected tab's surface is visible.
|
||||
function terminalSurface(page: Page) {
|
||||
return page.getByTestId("terminal-surface").filter({ visible: true });
|
||||
}
|
||||
|
||||
function terminalInput(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"] .xterm-helper-textarea');
|
||||
return terminalSurface(page).locator(".xterm-helper-textarea");
|
||||
}
|
||||
|
||||
async function pasteImage(page: Page, bytes: Buffer): Promise<void> {
|
||||
@@ -127,8 +133,7 @@ async function dropFiles(page: Page, files: DroppedFile[]): Promise<void> {
|
||||
|
||||
/** The shared "Drop files here" overlay of the zone around the terminal. */
|
||||
function terminalDropOverlay(page: Page) {
|
||||
return page
|
||||
.getByTestId("terminal-surface")
|
||||
return terminalSurface(page)
|
||||
.locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
|
||||
.getByTestId("file-drop-backdrop");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"start": "npm run start:expo",
|
||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps",
|
||||
"android": "npm run dev:android",
|
||||
"android:development": "npm run dev:android",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
@@ -35,8 +35,6 @@
|
||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"check:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs --check",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
|
||||
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
|
||||
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
|
||||
|
||||
async function resolveTsPath(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.tsx"),
|
||||
path.join(basePath, "index.js"),
|
||||
path.join(basePath, "index.jsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const aliasPlugin = {
|
||||
name: "paseo-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, async (args) => ({
|
||||
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
|
||||
}));
|
||||
build.onResolve({ filter: /^@server\// }, async (args) => ({
|
||||
path: await resolveTsPath(
|
||||
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
|
||||
),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
absWorkingDir: repoRoot,
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
loader: {
|
||||
".css": "text",
|
||||
},
|
||||
plugins: [aliasPlugin],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const js = result.outputFiles[0]?.text;
|
||||
if (!js) {
|
||||
throw new Error("terminal webview bundle produced no JavaScript");
|
||||
}
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
// Compare parsed modules so repository formatting does not create false drift.
|
||||
const options = { loader: "ts", format: "esm" };
|
||||
const expected = await esbuild.transform(contents, options);
|
||||
const actual = await esbuild.transform(await fs.readFile(output, "utf8"), options);
|
||||
if (actual.code !== expected.code) {
|
||||
throw new Error(
|
||||
"Stale terminal WebView HTML. Run npm run build:terminal-webview --workspace=@getpaseo/app",
|
||||
);
|
||||
}
|
||||
console.log("Terminal WebView HTML is current");
|
||||
} else {
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
}
|
||||
@@ -1,724 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
import {
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type {
|
||||
TerminalOutputData,
|
||||
TerminalFindResult,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
TerminalLocalFileLinkTarget,
|
||||
} from "../terminal/local-links/terminal-local-link-provider";
|
||||
import { terminalFileFromBase64 } from "../terminal/file-paste";
|
||||
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract";
|
||||
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
|
||||
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
|
||||
|
||||
type BridgeInboundMessage =
|
||||
| {
|
||||
type: "mount";
|
||||
streamKey: string;
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}
|
||||
| { type: "unmount"; streamKey: string }
|
||||
| { type: "writeOutput"; streamKey: string; text: string }
|
||||
| { type: "restoreOutput"; streamKey: string; text: string }
|
||||
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
|
||||
| { type: "paste"; streamKey: string; text: string }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
|
||||
| { type: "clearFind"; streamKey: string }
|
||||
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
|
||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
||||
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
|
||||
| {
|
||||
type: "resolveLocalFileLinkResponse";
|
||||
streamKey: string;
|
||||
requestId: number;
|
||||
target: TerminalLocalFileLinkTarget | null;
|
||||
};
|
||||
|
||||
type BridgeOutboundMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| { type: "rendererReady"; streamKey: string; isReady: boolean }
|
||||
| { type: "input"; streamKey: string; data: string }
|
||||
| { type: "findRequest"; streamKey: string }
|
||||
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
|
||||
| {
|
||||
type: "resize";
|
||||
streamKey: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim?: boolean;
|
||||
forceClaim?: boolean;
|
||||
}
|
||||
| {
|
||||
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: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: 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 };
|
||||
|
||||
const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
|
||||
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
|
||||
const BRIDGE_READY_TIMEOUT_MS = 2_500;
|
||||
const RENDERER_READY_TIMEOUT_MS = 2_500;
|
||||
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
|
||||
type WebViewProps = ComponentProps<typeof WebView>;
|
||||
interface PendingTerminalTap {
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
function buildThemeKey(theme: ITheme): string {
|
||||
return JSON.stringify(theme);
|
||||
}
|
||||
|
||||
function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
|
||||
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
|
||||
}
|
||||
|
||||
function createMountMessage(input: {
|
||||
streamKey: string;
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}): BridgeInboundMessage {
|
||||
return {
|
||||
type: "mount",
|
||||
streamKey: input.streamKey,
|
||||
initialSnapshot: input.initialSnapshot,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
theme: input.theme,
|
||||
fontFamily: input.fontFamily,
|
||||
fontSize: input.fontSize,
|
||||
pendingModifiers: input.pendingModifiers,
|
||||
swipeGesturesEnabled: input.swipeGesturesEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export default function WebViewTerminalEmulator({
|
||||
ref,
|
||||
streamKey,
|
||||
testId = "terminal-surface",
|
||||
xtermTheme = {
|
||||
background: "#0b0b0b",
|
||||
foreground: "#e6e6e6",
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
initialSnapshot = null,
|
||||
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 webViewRef = useRef<WebView>(null);
|
||||
const [webViewEpoch, setWebViewEpoch] = useState(0);
|
||||
const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
|
||||
const bridgeReadyRef = useRef(false);
|
||||
const bridgeReadyVersionRef = useRef(0);
|
||||
const rendererReadyVersionRef = useRef(0);
|
||||
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
|
||||
const outputDecoderRef = useRef(new TextDecoder());
|
||||
const mountRequestedStreamKeyRef = useRef<string | null>(null);
|
||||
const rendererReadyStreamKeyRef = useRef<string | null>(null);
|
||||
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingTapRef = useRef<PendingTerminalTap | null>(null);
|
||||
const mountConfigRef = useRef({
|
||||
streamKey,
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
});
|
||||
mountConfigRef.current = {
|
||||
streamKey,
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
};
|
||||
const callbacksRef = useRef({
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onRendererReadyChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onRendererReadyChange,
|
||||
onResolveLocalFileLink,
|
||||
onOpenLocalFileLink,
|
||||
onFilePaste,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
};
|
||||
|
||||
const clearBridgeReadyTimeout = useCallback(() => {
|
||||
if (bridgeReadyTimeoutRef.current === null) return;
|
||||
clearTimeout(bridgeReadyTimeoutRef.current);
|
||||
bridgeReadyTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearRendererReadyTimeout = useCallback(() => {
|
||||
if (rendererReadyTimeoutRef.current === null) return;
|
||||
clearTimeout(rendererReadyTimeoutRef.current);
|
||||
rendererReadyTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resetWebViewDocument = useCallback(() => {
|
||||
clearBridgeReadyTimeout();
|
||||
clearRendererReadyTimeout();
|
||||
bridgeReadyRef.current = false;
|
||||
pendingMessagesRef.current = [];
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
|
||||
setWebViewEpoch((value) => value + 1);
|
||||
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
|
||||
|
||||
const scheduleBridgeReadyWatchdog = useCallback(() => {
|
||||
clearBridgeReadyTimeout();
|
||||
const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
|
||||
bridgeReadyTimeoutRef.current = setTimeout(() => {
|
||||
bridgeReadyTimeoutRef.current = null;
|
||||
if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
|
||||
return;
|
||||
}
|
||||
resetWebViewDocument();
|
||||
}, BRIDGE_READY_TIMEOUT_MS);
|
||||
}, [clearBridgeReadyTimeout, resetWebViewDocument]);
|
||||
|
||||
const scheduleRendererReadyWatchdog = useCallback(() => {
|
||||
clearRendererReadyTimeout();
|
||||
const expectedRendererReadyVersion = rendererReadyVersionRef.current;
|
||||
rendererReadyTimeoutRef.current = setTimeout(() => {
|
||||
rendererReadyTimeoutRef.current = null;
|
||||
if (
|
||||
!shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: expectedRendererReadyVersion,
|
||||
currentReadyVersion: rendererReadyVersionRef.current,
|
||||
expectedStreamKey: streamKey,
|
||||
mountRequestedStreamKey: mountRequestedStreamKeyRef.current,
|
||||
rendererReadyStreamKey: rendererReadyStreamKeyRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
resetWebViewDocument();
|
||||
}, RENDERER_READY_TIMEOUT_MS);
|
||||
}, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
|
||||
|
||||
const flushPendingMessages = useCallback(() => {
|
||||
if (!bridgeReadyRef.current || !webViewRef.current) return;
|
||||
const pending = pendingMessagesRef.current.splice(0);
|
||||
for (const message of pending) {
|
||||
const payload = serializeForInjectedJavaScript(message);
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendToWebView = useCallback((message: BridgeInboundMessage) => {
|
||||
if (!bridgeReadyRef.current || !webViewRef.current) {
|
||||
pendingMessagesRef.current.push(message);
|
||||
return;
|
||||
}
|
||||
const payload = serializeForInjectedJavaScript(message);
|
||||
webViewRef.current.injectJavaScript(
|
||||
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TerminalEmulatorHandle => ({
|
||||
find: {
|
||||
setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }),
|
||||
search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }),
|
||||
clear: () => sendToWebView({ type: "clearFind", streamKey }),
|
||||
},
|
||||
writeOutput: (data: TerminalOutputData) => {
|
||||
const output = outputDecoderRef.current.decode(data, { stream: true });
|
||||
if (output.length === 0) {
|
||||
return;
|
||||
}
|
||||
sendToWebView({ type: "writeOutput", streamKey, text: output });
|
||||
},
|
||||
restoreOutput: (data: TerminalOutputData) => {
|
||||
outputDecoderRef.current.decode();
|
||||
const text = outputDecoderRef.current.decode(data, { stream: false });
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
sendToWebView({ type: "restoreOutput", streamKey, text });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
outputDecoderRef.current.decode();
|
||||
sendToWebView({ type: "renderSnapshot", streamKey, state });
|
||||
},
|
||||
paste: (text: string) => {
|
||||
sendToWebView({ type: "paste", streamKey, text });
|
||||
},
|
||||
copySelection: async () => "",
|
||||
clear: () => {
|
||||
outputDecoderRef.current.decode();
|
||||
sendToWebView({ type: "clear", streamKey });
|
||||
},
|
||||
claimSize: () => {
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: true });
|
||||
},
|
||||
showKeyboard: () => {
|
||||
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
|
||||
webViewRef.current?.requestFocus();
|
||||
},
|
||||
blur: () => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
"window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;",
|
||||
);
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
}),
|
||||
[sendToWebView, streamKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
outputDecoderRef.current.decode();
|
||||
}, [streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bridgeReadyVersion <= 0) return;
|
||||
const mountMessage = createMountMessage(mountConfigRef.current);
|
||||
mountRequestedStreamKeyRef.current = streamKey;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
sendToWebView(mountMessage);
|
||||
flushPendingMessages();
|
||||
scheduleRendererReadyWatchdog();
|
||||
}, [
|
||||
bridgeReadyVersion,
|
||||
flushPendingMessages,
|
||||
scheduleRendererReadyWatchdog,
|
||||
sendToWebView,
|
||||
streamKey,
|
||||
]);
|
||||
|
||||
const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
|
||||
}, [sendToWebView, streamKey, themeKey, xtermTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
|
||||
}, [scrollbackLines, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
|
||||
}, [fontFamily, fontSize, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
|
||||
}, [pendingModifiers, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRequestedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
|
||||
}, [sendToWebView, streamKey, swipeGesturesEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) return;
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: true });
|
||||
sendToWebView({ type: "focus", streamKey });
|
||||
webViewRef.current?.requestFocus();
|
||||
}, [focusRequestToken, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resizeRequestToken <= 0) return;
|
||||
sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false });
|
||||
}, [resizeRequestToken, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mountRequestedStreamKeyRef.current) {
|
||||
const previousStreamKey = mountRequestedStreamKeyRef.current;
|
||||
callbacksRef.current.onRendererReadyChange?.({
|
||||
streamKey: previousStreamKey,
|
||||
isReady: false,
|
||||
});
|
||||
sendToWebView({ type: "unmount", streamKey: previousStreamKey });
|
||||
}
|
||||
bridgeReadyRef.current = false;
|
||||
pendingMessagesRef.current = [];
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
clearBridgeReadyTimeout();
|
||||
clearRendererReadyTimeout();
|
||||
};
|
||||
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
|
||||
|
||||
const handleLifecycleMessage = useCallback(
|
||||
(message: BridgeOutboundMessage): boolean => {
|
||||
if (message.type === "bridgeReady") {
|
||||
bridgeReadyRef.current = true;
|
||||
bridgeReadyVersionRef.current += 1;
|
||||
clearBridgeReadyTimeout();
|
||||
setBridgeReadyVersion((value) => value + 1);
|
||||
return true;
|
||||
}
|
||||
if (message.type === "rendererReady") {
|
||||
if (message.streamKey === mountRequestedStreamKeyRef.current) {
|
||||
rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null;
|
||||
if (message.isReady) {
|
||||
rendererReadyVersionRef.current += 1;
|
||||
clearRendererReadyTimeout();
|
||||
}
|
||||
}
|
||||
callbacksRef.current.onRendererReadyChange?.({
|
||||
streamKey: message.streamKey,
|
||||
isReady: message.isReady,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
|
||||
);
|
||||
|
||||
const resolveLocalFileLink = useCallback(
|
||||
async (message: Extract<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
|
||||
try {
|
||||
const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
|
||||
null);
|
||||
sendToWebView({
|
||||
type: "resolveLocalFileLinkResponse",
|
||||
streamKey: message.streamKey,
|
||||
requestId: message.requestId,
|
||||
target,
|
||||
});
|
||||
} catch {
|
||||
sendToWebView({
|
||||
type: "resolveLocalFileLinkResponse",
|
||||
streamKey: message.streamKey,
|
||||
requestId: message.requestId,
|
||||
target: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[sendToWebView],
|
||||
);
|
||||
|
||||
const handleTerminalMessage = useCallback(
|
||||
(
|
||||
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
|
||||
) => {
|
||||
if (message.type === "resolveLocalFileLink") {
|
||||
void resolveLocalFileLink(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "openLocalFileLink") {
|
||||
callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
|
||||
return;
|
||||
}
|
||||
switch (message.type) {
|
||||
case "input":
|
||||
callbacksRef.current.onInput?.(message.data);
|
||||
break;
|
||||
case "resize":
|
||||
callbacksRef.current.onResize?.({
|
||||
rows: message.rows,
|
||||
cols: message.cols,
|
||||
shouldClaim: message.shouldClaim !== false,
|
||||
forceClaim: message.forceClaim,
|
||||
});
|
||||
break;
|
||||
case "terminalKey":
|
||||
callbacksRef.current.onTerminalKey?.({
|
||||
key: message.key,
|
||||
ctrl: message.ctrl,
|
||||
shift: message.shift,
|
||||
alt: message.alt,
|
||||
meta: message.meta,
|
||||
});
|
||||
break;
|
||||
case "pendingModifiersConsumed":
|
||||
callbacksRef.current.onPendingModifiersConsumed?.();
|
||||
break;
|
||||
case "inputModeChange":
|
||||
callbacksRef.current.onInputModeChange?.(message.state);
|
||||
break;
|
||||
case "openExternalUrl":
|
||||
void openExternalUrl(message.url);
|
||||
break;
|
||||
case "swipeLeft":
|
||||
callbacksRef.current.onSwipeLeft?.();
|
||||
break;
|
||||
case "swipeRight":
|
||||
callbacksRef.current.onSwipeRight?.();
|
||||
break;
|
||||
case "debug":
|
||||
break;
|
||||
}
|
||||
},
|
||||
[resolveLocalFileLink],
|
||||
);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let message: BridgeOutboundMessage;
|
||||
try {
|
||||
message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "bridgeReady" || message.type === "rendererReady") {
|
||||
handleLifecycleMessage(message);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!shouldHandleTerminalBridgeMessage(message, {
|
||||
desiredStreamKey: streamKey,
|
||||
mountedStreamKey: mountRequestedStreamKeyRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "findRequest") {
|
||||
callbacksRef.current.onFindRequest?.();
|
||||
return;
|
||||
}
|
||||
if (message.type === "findResult") {
|
||||
callbacksRef.current.onFindResult?.(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.type === "filePaste") {
|
||||
callbacksRef.current.onFilePaste?.(Promise.resolve(terminalFileFromBase64(message)));
|
||||
return;
|
||||
}
|
||||
handleTerminalMessage(message);
|
||||
},
|
||||
[handleLifecycleMessage, handleTerminalMessage, streamKey],
|
||||
);
|
||||
|
||||
const handleLoadStart = useCallback<NonNullable<WebViewProps["onLoadStart"]>>(() => {
|
||||
bridgeReadyRef.current = false;
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
scheduleBridgeReadyWatchdog();
|
||||
}, [scheduleBridgeReadyWatchdog]);
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback<
|
||||
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
|
||||
>(() => {
|
||||
resetWebViewDocument();
|
||||
}, [resetWebViewDocument]);
|
||||
|
||||
const handleRenderProcessGone = useCallback<
|
||||
NonNullable<WebViewProps["onRenderProcessGone"]>
|
||||
>(() => {
|
||||
resetWebViewDocument();
|
||||
}, [resetWebViewDocument]);
|
||||
|
||||
const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
|
||||
pendingTapRef.current = {
|
||||
startX: event.nativeEvent.pageX,
|
||||
startY: event.nativeEvent.pageY,
|
||||
moved: false,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
|
||||
const pendingTap = pendingTapRef.current;
|
||||
if (!pendingTap) return;
|
||||
const dx = event.nativeEvent.pageX - pendingTap.startX;
|
||||
const dy = event.nativeEvent.pageY - pendingTap.startY;
|
||||
if (
|
||||
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
|
||||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
|
||||
) {
|
||||
pendingTap.moved = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWebViewTouchEnd = useCallback(() => {
|
||||
const pendingTap = pendingTapRef.current;
|
||||
pendingTapRef.current = null;
|
||||
if (!pendingTap || pendingTap.moved) {
|
||||
return;
|
||||
}
|
||||
webViewRef.current?.requestFocus();
|
||||
callbacksRef.current.onFocus?.();
|
||||
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
|
||||
}, [sendToWebView, streamKey]);
|
||||
|
||||
const handleWebViewTouchCancel = useCallback(() => {
|
||||
pendingTapRef.current = null;
|
||||
}, []);
|
||||
|
||||
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
|
||||
const rootStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.root, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webView, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webViewContainer, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rootStyle} testID={testId}>
|
||||
<WebView
|
||||
key={webViewEpoch}
|
||||
ref={webViewRef}
|
||||
source={TERMINAL_WEBVIEW_SOURCE}
|
||||
style={webViewStyle}
|
||||
containerStyle={webViewContainerStyle}
|
||||
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
|
||||
scrollEnabled
|
||||
nestedScrollEnabled
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
textInteractionEnabled
|
||||
allowsLinkPreview={false}
|
||||
setSupportMultipleWindows={false}
|
||||
setBuiltInZoomControls={false}
|
||||
setDisplayZoomControls={false}
|
||||
textZoom={100}
|
||||
onTouchStart={handleWebViewTouchStart}
|
||||
onTouchMove={handleWebViewTouchMove}
|
||||
onTouchEnd={handleWebViewTouchEnd}
|
||||
onTouchCancel={handleWebViewTouchCancel}
|
||||
onMessage={handleMessage}
|
||||
onLoadStart={handleLoadStart}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
webViewContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -2,10 +2,8 @@ import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useAppSettings } from "../hooks/use-settings";
|
||||
import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability";
|
||||
import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native";
|
||||
import WebViewTerminalEmulator from "./terminal-emulator-webview.native";
|
||||
import type { TerminalEmulatorProps } from "./terminal-emulator-contract";
|
||||
|
||||
export type { TerminalEmulatorHandle } from "./terminal-emulator-contract";
|
||||
@@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina
|
||||
}
|
||||
|
||||
export default function TerminalEmulator(props: TerminalEmulatorProps) {
|
||||
const { settings } = useAppSettings();
|
||||
const { useLegacyTerminalRenderer } = settings;
|
||||
const rendererSelection = resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer,
|
||||
supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay,
|
||||
});
|
||||
|
||||
@@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) {
|
||||
return <UnsupportedNativeTerminal {...props} />;
|
||||
}
|
||||
|
||||
const Renderer =
|
||||
rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator;
|
||||
|
||||
return <Renderer key={rendererSelection} {...props} />;
|
||||
return <NativeGridTerminalEmulator {...props} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
|
||||
|
||||
describe("terminal WebView message routing", () => {
|
||||
it("rejects terminal messages from a stale stream", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "resize", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the old WebView stream while React is switching callbacks", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-a" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts messages from the mounted stream and unscoped diagnostics", () => {
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "input", streamKey: "terminal-b" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHandleTerminalBridgeMessage(
|
||||
{ type: "debug" },
|
||||
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
interface TerminalBridgeMessageIdentity {
|
||||
type: string;
|
||||
streamKey?: string;
|
||||
}
|
||||
|
||||
export function shouldHandleTerminalBridgeMessage(
|
||||
message: TerminalBridgeMessageIdentity,
|
||||
stream: {
|
||||
desiredStreamKey: string;
|
||||
mountedStreamKey: string | null;
|
||||
},
|
||||
): boolean {
|
||||
return (
|
||||
message.streamKey === undefined ||
|
||||
(message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey)
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
|
||||
|
||||
describe("legacy terminal WebView readiness", () => {
|
||||
it("resets when a requested mount never reports renderer readiness", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the document after the requested renderer confirms readiness", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: "terminal-a",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a watchdog from an earlier ready generation", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 1,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-a",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let readiness from a stale stream satisfy the requested mount", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-b",
|
||||
mountRequestedStreamKey: "terminal-b",
|
||||
rendererReadyStreamKey: "terminal-a",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reset a newer mount from an expired watchdog", () => {
|
||||
expect(
|
||||
shouldResetForMissingRendererReady({
|
||||
expectedReadyVersion: 0,
|
||||
currentReadyVersion: 0,
|
||||
expectedStreamKey: "terminal-a",
|
||||
mountRequestedStreamKey: "terminal-b",
|
||||
rendererReadyStreamKey: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
export function shouldResetForMissingRendererReady(input: {
|
||||
expectedReadyVersion: number;
|
||||
currentReadyVersion: number;
|
||||
expectedStreamKey: string;
|
||||
mountRequestedStreamKey: string | null;
|
||||
rendererReadyStreamKey: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
input.currentReadyVersion === input.expectedReadyVersion &&
|
||||
input.mountRequestedStreamKey === input.expectedStreamKey &&
|
||||
input.rendererReadyStreamKey !== input.expectedStreamKey
|
||||
);
|
||||
}
|
||||
@@ -389,26 +389,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(legacySide.openInSidePane).not.toHaveProperty("pullRequests");
|
||||
});
|
||||
|
||||
it("uses the native terminal renderer by default", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.useLegacyTerminalRenderer).toBe(false);
|
||||
});
|
||||
|
||||
it("loads the per-device legacy terminal renderer preference", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ useLegacyTerminalRenderer: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.useLegacyTerminalRenderer).toBe(true);
|
||||
});
|
||||
|
||||
it("loads configured terminal scrollback lines from app settings", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
|
||||
@@ -81,7 +81,6 @@ export interface AppSettings {
|
||||
sendBehavior: SendBehavior;
|
||||
serviceUrlBehavior: ServiceUrlBehavior;
|
||||
terminalScrollbackLines: number;
|
||||
useLegacyTerminalRenderer: boolean;
|
||||
uiFontFamily: string; // "" = platform default UI stack
|
||||
monoFontFamily: string; // "" = platform default mono stack
|
||||
uiBaseFontSize: number; // clamped px, platform default 14 or 15
|
||||
@@ -142,7 +141,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sendBehavior: "steer",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
useLegacyTerminalRenderer: false,
|
||||
uiFontFamily: "",
|
||||
monoFontFamily: "",
|
||||
uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
@@ -224,7 +222,6 @@ const StoredAppSettingsSchema = z
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES),
|
||||
useLegacyTerminalRenderer: z.boolean().catch(false),
|
||||
uiFontFamily: sanitizedFontFamily().catch(""),
|
||||
monoFontFamily: sanitizedFontFamily().catch(""),
|
||||
uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE)
|
||||
|
||||
@@ -369,9 +369,6 @@ describe("translation resources", () => {
|
||||
|
||||
it("includes Settings expansion keys for the Batch 3A migration", () => {
|
||||
expect(en.settings.diagnostics.title).toBe("Diagnostics");
|
||||
expect(en.settings.diagnostics.legacyTerminalRenderer.label).toBe(
|
||||
"Use legacy terminal renderer",
|
||||
);
|
||||
expect(en.settings.about.title).toBe("About");
|
||||
expect(en.settings.about.releaseChannel.label).toBe("Release channel");
|
||||
expect(en.settings.appearance.theme.title).toBe("Theme");
|
||||
|
||||
@@ -2332,11 +2332,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "التشخيص",
|
||||
legacyTerminalRenderer: {
|
||||
label: "استخدام عارض الطرفية السابق",
|
||||
description: "استخدام طرفية WebView السابقة بعد إعادة فتح الطرفية",
|
||||
accessibilityLabel: "استخدام عارض الطرفية السابق",
|
||||
},
|
||||
testAudio: "اختبار الصوت",
|
||||
playTest: "لعب الاختبار",
|
||||
playing: "جارٍ اللعب...",
|
||||
|
||||
@@ -2420,11 +2420,6 @@ export const en = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Diagnostics",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Use legacy terminal renderer",
|
||||
description: "Use the previous WebView terminal after reopening a terminal",
|
||||
accessibilityLabel: "Use legacy terminal renderer",
|
||||
},
|
||||
testAudio: "Test audio",
|
||||
playTest: "Play test",
|
||||
playing: "Playing...",
|
||||
|
||||
@@ -2348,11 +2348,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Diagnóstico",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Usar el renderizador de terminal anterior",
|
||||
description: "Usa el terminal WebView anterior al volver a abrir un terminal",
|
||||
accessibilityLabel: "Usar el renderizador de terminal anterior",
|
||||
},
|
||||
testAudio: "audio de prueba",
|
||||
playTest: "Prueba de juego",
|
||||
playing: "Jugando...",
|
||||
|
||||
@@ -2346,11 +2346,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Diagnostic",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Utiliser l’ancien rendu du terminal",
|
||||
description: "Utilise l’ancien terminal WebView après la réouverture d’un terminal",
|
||||
accessibilityLabel: "Utiliser l’ancien rendu du terminal",
|
||||
},
|
||||
testAudio: "Tester le son",
|
||||
playTest: "Lancer le test",
|
||||
playing: "Lecture…",
|
||||
|
||||
@@ -2316,11 +2316,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "診断",
|
||||
legacyTerminalRenderer: {
|
||||
label: "以前のターミナルレンダラーを使用",
|
||||
description: "ターミナルを開き直した後、以前の WebView ターミナルを使用します",
|
||||
accessibilityLabel: "以前のターミナルレンダラーを使用",
|
||||
},
|
||||
testAudio: "音声をテスト",
|
||||
playTest: "テスト再生",
|
||||
playing: "再生中...",
|
||||
|
||||
@@ -2307,11 +2307,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "진단",
|
||||
legacyTerminalRenderer: {
|
||||
label: "레거시 터미널 렌더러 사용",
|
||||
description: "터미널을 다시 열 때 이전 WebView 터미널을 사용합니다.",
|
||||
accessibilityLabel: "레거시 터미널 렌더러 사용",
|
||||
},
|
||||
testAudio: "오디오 테스트",
|
||||
playTest: "테스트 재생",
|
||||
playing: "재생 중...",
|
||||
|
||||
@@ -2331,11 +2331,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Diagnósticos",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Usar renderizador de terminal anterior",
|
||||
description: "Usa o terminal WebView anterior após reabrir um terminal",
|
||||
accessibilityLabel: "Usar renderizador de terminal anterior",
|
||||
},
|
||||
testAudio: "Testar áudio",
|
||||
playTest: "Reproduzir teste",
|
||||
playing: "Reproduzindo...",
|
||||
|
||||
@@ -2333,11 +2333,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "Диагностика",
|
||||
legacyTerminalRenderer: {
|
||||
label: "Использовать прежний рендерер терминала",
|
||||
description: "Использовать прежний WebView-терминал после повторного открытия",
|
||||
accessibilityLabel: "Использовать прежний рендерер терминала",
|
||||
},
|
||||
testAudio: "Проверка звука",
|
||||
playTest: "Воспроизвести тестовый звук",
|
||||
playing: "Воспроизведение...",
|
||||
|
||||
@@ -2271,11 +2271,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
diagnostics: {
|
||||
title: "诊断",
|
||||
legacyTerminalRenderer: {
|
||||
label: "使用旧版终端渲染器",
|
||||
description: "重新打开终端后使用之前的 WebView 终端",
|
||||
accessibilityLabel: "使用旧版终端渲染器",
|
||||
},
|
||||
testAudio: "测试音频",
|
||||
playTest: "播放测试",
|
||||
playing: "正在播放...",
|
||||
|
||||
@@ -81,7 +81,6 @@ import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-
|
||||
import { EditorSection } from "@/screens/settings/editor-section";
|
||||
import { FilesSection } from "@/screens/settings/files-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CommunityLinks } from "@/components/community-links";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
@@ -136,7 +135,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View model
|
||||
@@ -383,8 +382,6 @@ function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps)
|
||||
}
|
||||
|
||||
interface DiagnosticsSectionProps {
|
||||
useLegacyTerminalRenderer: boolean;
|
||||
onUseLegacyTerminalRendererChange: (value: boolean) => void;
|
||||
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
||||
isPlaybackTestRunning: boolean;
|
||||
playbackTestResult: string | null;
|
||||
@@ -392,8 +389,6 @@ interface DiagnosticsSectionProps {
|
||||
}
|
||||
|
||||
function DiagnosticsSection({
|
||||
useLegacyTerminalRenderer,
|
||||
onUseLegacyTerminalRendererChange,
|
||||
voiceAudioEngine,
|
||||
isPlaybackTestRunning,
|
||||
playbackTestResult,
|
||||
@@ -407,26 +402,6 @@ function DiagnosticsSection({
|
||||
return (
|
||||
<SettingsSection title={t("settings.diagnostics.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{isNative ? (
|
||||
<View style={settingsStyles.row} testID="legacy-terminal-renderer-row">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.diagnostics.legacyTerminalRenderer.label")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.diagnostics.legacyTerminalRenderer.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={useLegacyTerminalRenderer}
|
||||
onValueChange={onUseLegacyTerminalRendererChange}
|
||||
accessibilityLabel={t(
|
||||
"settings.diagnostics.legacyTerminalRenderer.accessibilityLabel",
|
||||
)}
|
||||
testID="legacy-terminal-renderer-switch"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={settingsStyles.row} testID="app-diagnostic-row">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.app.rowTitle")}</Text>
|
||||
@@ -1159,13 +1134,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleUseLegacyTerminalRendererChange = useCallback(
|
||||
(useLegacyTerminalRenderer: boolean) => {
|
||||
void updateSettings({ useLegacyTerminalRenderer });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
@@ -1390,8 +1358,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
case "diagnostics":
|
||||
return (
|
||||
<DiagnosticsSection
|
||||
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
|
||||
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
|
||||
voiceAudioEngine={voiceAudioEngine}
|
||||
isPlaybackTestRunning={isPlaybackTestRunning}
|
||||
playbackTestResult={playbackTestResult}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Bundled into the terminal WebView as well as the app: keep i18n and React out.
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
|
||||
import { prepareTerminalPath } from "../drop/terminal-path";
|
||||
|
||||
@@ -126,16 +125,6 @@ export function terminalFileFromBase64(input: {
|
||||
return { fileName: input.fileName, mimeType: input.mimeType, bytes };
|
||||
}
|
||||
|
||||
const BASE64_CHUNK_SIZE = 0x8000;
|
||||
|
||||
export function terminalFileToBase64(file: TerminalFile): string {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < file.bytes.length; offset += BASE64_CHUNK_SIZE) {
|
||||
binary += String.fromCharCode(...file.bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** File extension for a clipboard image, which arrives as bytes with only a MIME type. */
|
||||
export function imageExtension(mimeType: string): string {
|
||||
const subtype = mimeType.split("/")[1]?.split(/[+;]/)[0] ?? "";
|
||||
|
||||
@@ -4,29 +4,14 @@ import { resolveTerminalRendererSelection } from "./terminal-renderer-capability
|
||||
|
||||
describe("terminal renderer capability", () => {
|
||||
it("requires input mode replay before attaching the native renderer", () => {
|
||||
expect(
|
||||
resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer: false,
|
||||
supportsTerminalInputModeReplay: false,
|
||||
}),
|
||||
).toBe("update-host");
|
||||
expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: false })).toBe(
|
||||
"update-host",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows the native renderer when the daemon advertises input mode replay", () => {
|
||||
expect(
|
||||
resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer: false,
|
||||
supportsTerminalInputModeReplay: true,
|
||||
}),
|
||||
).toBe("native-grid");
|
||||
});
|
||||
|
||||
it("keeps the explicit legacy renderer available with older daemons", () => {
|
||||
expect(
|
||||
resolveTerminalRendererSelection({
|
||||
useLegacyTerminalRenderer: true,
|
||||
supportsTerminalInputModeReplay: false,
|
||||
}),
|
||||
).toBe("webview");
|
||||
expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: true })).toBe(
|
||||
"native-grid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
export type TerminalRendererSelection = "native-grid" | "webview" | "update-host";
|
||||
export type TerminalRendererSelection = "native-grid" | "update-host";
|
||||
|
||||
export function resolveTerminalRendererSelection(input: {
|
||||
useLegacyTerminalRenderer: boolean;
|
||||
supportsTerminalInputModeReplay: boolean;
|
||||
}): TerminalRendererSelection {
|
||||
if (input.useLegacyTerminalRenderer) {
|
||||
return "webview";
|
||||
}
|
||||
return input.supportsTerminalInputModeReplay ? "native-grid" : "update-host";
|
||||
}
|
||||
|
||||
@@ -1,593 +0,0 @@
|
||||
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import xtermCss from "@xterm/xterm/css/xterm.css";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/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";
|
||||
import { terminalFileToBase64, type TerminalFile } from "../file-paste";
|
||||
|
||||
interface MountMessage {
|
||||
type: "mount";
|
||||
streamKey: string;
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
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: "paste"; streamKey: string; text: string }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
|
||||
| { type: "clearFind"; streamKey: string }
|
||||
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: 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: "findRequest"; streamKey: string }
|
||||
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
|
||||
| {
|
||||
type: "resize";
|
||||
streamKey: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim?: boolean;
|
||||
forceClaim?: boolean;
|
||||
}
|
||||
| {
|
||||
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: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: 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;
|
||||
__PASEO_TERMINAL_WEBVIEW_BLUR__?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const sendToNative = (message: OutboundMessage): void => {
|
||||
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
|
||||
};
|
||||
|
||||
const TERMINAL_BACKGROUND_CSS_VAR = "--paseo-terminal-background";
|
||||
const DEFAULT_TERMINAL_BACKGROUND = "#0b0b0b";
|
||||
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
|
||||
|
||||
function getTerminalBackground(theme: ITheme): string {
|
||||
return theme.background ?? DEFAULT_TERMINAL_BACKGROUND;
|
||||
}
|
||||
|
||||
const installStyles = (): void => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
${xtermCss}
|
||||
:root {
|
||||
${TERMINAL_BACKGROUND_CSS_VAR}: transparent;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#terminal-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
background: var(${TERMINAL_BACKGROUND_CSS_VAR});
|
||||
}
|
||||
#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;
|
||||
background: var(${TERMINAL_BACKGROUND_CSS_VAR});
|
||||
}
|
||||
#terminal-root .xterm,
|
||||
#terminal-root .xterm-screen,
|
||||
#terminal-root .xterm-viewport {
|
||||
background-color: var(${TERMINAL_BACKGROUND_CSS_VAR}) !important;
|
||||
}
|
||||
[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;
|
||||
}
|
||||
#terminal-root .xterm .xterm-helper-textarea {
|
||||
opacity: 0.01;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
min-width: 1px;
|
||||
min-height: 1px;
|
||||
color: transparent;
|
||||
background: transparent;
|
||||
caret-color: transparent;
|
||||
z-index: 5 !important;
|
||||
}
|
||||
`;
|
||||
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 filePastes: Promise<void> = Promise.resolve();
|
||||
private swipeGesturesEnabled = false;
|
||||
private trackingSwipe = false;
|
||||
private activePointerId: number | null = null;
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private firedSwipe = false;
|
||||
private tapTouchIdentifier: number | null = null;
|
||||
private tapStartX = 0;
|
||||
private tapStartY = 0;
|
||||
private tapMoved = false;
|
||||
|
||||
constructor(
|
||||
private readonly root: HTMLDivElement,
|
||||
private readonly host: HTMLDivElement,
|
||||
) {
|
||||
this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true });
|
||||
this.root.addEventListener("touchstart", this.handleTouchStart, { passive: true });
|
||||
this.root.addEventListener("touchmove", this.handleTouchMove, { passive: true });
|
||||
this.root.addEventListener("touchend", this.handleTouchEnd, { passive: true });
|
||||
this.root.addEventListener("touchcancel", this.handleTouchCancel, { 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;
|
||||
}
|
||||
if (message.type === "findWidgetSize") {
|
||||
this.runtime?.find.setWidgetSize(message.size);
|
||||
return;
|
||||
}
|
||||
if (message.type === "find") {
|
||||
this.runtime?.find.search(message.query, message.direction);
|
||||
return;
|
||||
}
|
||||
if (message.type === "clearFind") {
|
||||
this.runtime?.find.clear();
|
||||
return;
|
||||
}
|
||||
this.receiveMounted(message);
|
||||
}
|
||||
|
||||
private receiveMounted(
|
||||
message: Exclude<
|
||||
InboundMessage,
|
||||
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
|
||||
>,
|
||||
): void {
|
||||
if (this.receiveConfigurationMessage(message)) return;
|
||||
|
||||
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 "paste":
|
||||
this.runtime?.paste(message.text);
|
||||
break;
|
||||
case "clear":
|
||||
this.runtime?.clear();
|
||||
break;
|
||||
case "focus":
|
||||
this.runtime?.focus({ forceRefocus: message.forceRefocus });
|
||||
break;
|
||||
case "resize":
|
||||
this.runtime?.resize({
|
||||
shouldClaim: message.shouldClaim ?? true,
|
||||
forceClaim: message.forceClaim,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private receiveConfigurationMessage(
|
||||
message: Exclude<
|
||||
InboundMessage,
|
||||
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
|
||||
>,
|
||||
): boolean {
|
||||
switch (message.type) {
|
||||
case "setTheme":
|
||||
this.applyThemeBackground(message.theme);
|
||||
this.runtime?.setTheme({ theme: message.theme });
|
||||
return true;
|
||||
case "setScrollback":
|
||||
this.runtime?.setScrollback({ lines: message.lines });
|
||||
return true;
|
||||
case "setFont":
|
||||
this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize });
|
||||
return true;
|
||||
case "setPendingModifiers":
|
||||
this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
|
||||
return true;
|
||||
case "setSwipeGesturesEnabled":
|
||||
this.swipeGesturesEnabled = message.enabled;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private mount(message: MountMessage): void {
|
||||
this.unmount(this.streamKey);
|
||||
this.streamKey = message.streamKey;
|
||||
this.swipeGesturesEnabled = message.swipeGesturesEnabled;
|
||||
this.applyThemeBackground(message.theme);
|
||||
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
this.runtime = runtime;
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onFindRequest: () => sendToNative({ type: "findRequest", streamKey: message.streamKey }),
|
||||
onFindResult: (result) =>
|
||||
sendToNative({ type: "findResult", streamKey: message.streamKey, result }),
|
||||
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
|
||||
onResize: ({ rows, cols, shouldClaim, forceClaim }) =>
|
||||
sendToNative({
|
||||
type: "resize",
|
||||
streamKey: message.streamKey,
|
||||
rows,
|
||||
cols,
|
||||
shouldClaim,
|
||||
forceClaim,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
onFilePaste: (file) => this.forwardFilePaste(message.streamKey, file),
|
||||
},
|
||||
});
|
||||
runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
|
||||
runtime.mount({
|
||||
root: this.root,
|
||||
host: this.host,
|
||||
initialSnapshot: message.initialSnapshot,
|
||||
scrollback: message.scrollbackLines,
|
||||
theme: message.theme,
|
||||
fontFamily: message.fontFamily,
|
||||
fontSize: message.fontSize,
|
||||
});
|
||||
sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
|
||||
}
|
||||
|
||||
private applyThemeBackground(theme: ITheme): void {
|
||||
const background = getTerminalBackground(theme);
|
||||
document.documentElement.style.setProperty(TERMINAL_BACKGROUND_CSS_VAR, background);
|
||||
document.body.style.backgroundColor = background;
|
||||
this.root.style.backgroundColor = background;
|
||||
this.host.style.backgroundColor = background;
|
||||
}
|
||||
|
||||
blur = (): void => {
|
||||
this.runtime?.blur();
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Forward in gesture order: an earlier file can finish reading after a later one.
|
||||
private forwardFilePaste(streamKey: string, file: Promise<TerminalFile | null>): void {
|
||||
this.filePastes = this.filePastes.then(() => this.sendFilePaste(streamKey, file));
|
||||
}
|
||||
|
||||
private async sendFilePaste(
|
||||
streamKey: string,
|
||||
file: Promise<TerminalFile | null>,
|
||||
): Promise<void> {
|
||||
const read = await file.catch(() => null);
|
||||
if (read && this.matches(streamKey)) {
|
||||
sendToNative({
|
||||
type: "filePaste",
|
||||
streamKey,
|
||||
fileName: read.fileName,
|
||||
mimeType: read.mimeType,
|
||||
data: terminalFileToBase64(read),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 (!event.isPrimary) {
|
||||
return;
|
||||
}
|
||||
if (!this.swipeGesturesEnabled) {
|
||||
return;
|
||||
}
|
||||
this.trackingSwipe = true;
|
||||
this.firedSwipe = false;
|
||||
this.activePointerId = event.pointerId;
|
||||
this.startX = event.clientX;
|
||||
this.startY = event.clientY;
|
||||
};
|
||||
|
||||
private handleTouchStart = (event: TouchEvent): void => {
|
||||
if (event.touches.length !== 1) {
|
||||
this.resetTap();
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
this.tapTouchIdentifier = touch.identifier;
|
||||
this.tapStartX = touch.clientX;
|
||||
this.tapStartY = touch.clientY;
|
||||
this.tapMoved = false;
|
||||
};
|
||||
|
||||
private handleTouchMove = (event: TouchEvent): void => {
|
||||
const touch = this.findTrackedTouch(event.touches);
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
const dx = touch.clientX - this.tapStartX;
|
||||
const dy = touch.clientY - this.tapStartY;
|
||||
if (
|
||||
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
|
||||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
|
||||
) {
|
||||
this.tapMoved = true;
|
||||
}
|
||||
};
|
||||
|
||||
private handleTouchEnd = (event: TouchEvent): void => {
|
||||
const completedTap = Boolean(this.findTrackedTouch(event.changedTouches) && !this.tapMoved);
|
||||
this.resetTap();
|
||||
if (completedTap) {
|
||||
this.runtime?.focus({ forceRefocus: true });
|
||||
}
|
||||
};
|
||||
|
||||
private handleTouchCancel = (): void => {
|
||||
this.resetTap();
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private findTrackedTouch(touches: TouchList): Touch | null {
|
||||
if (this.tapTouchIdentifier === null) {
|
||||
return null;
|
||||
}
|
||||
for (let index = 0; index < touches.length; index += 1) {
|
||||
const touch = touches.item(index);
|
||||
if (touch?.identifier === this.tapTouchIdentifier) {
|
||||
return touch;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private resetTap(): void {
|
||||
this.tapTouchIdentifier = null;
|
||||
this.tapStartX = 0;
|
||||
this.tapStartY = 0;
|
||||
this.tapMoved = 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;
|
||||
window.__PASEO_TERMINAL_WEBVIEW_BLUR__ = bridge.blur;
|
||||
sendToNative({ type: "bridgeReady" });
|
||||
File diff suppressed because one or more lines are too long
@@ -1,104 +0,0 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { terminalEmulatorWebViewHtml } from "./terminal-emulator-webview-html";
|
||||
|
||||
interface Message {
|
||||
type: string;
|
||||
streamKey?: string;
|
||||
result?: { resultCount: number; resultIndex: number };
|
||||
data?: string;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
interface TerminalFrameWindow extends Window {
|
||||
__PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void;
|
||||
__paseoTerminal?: Terminal;
|
||||
}
|
||||
let frame: HTMLIFrameElement;
|
||||
afterEach(() => frame?.remove());
|
||||
|
||||
async function mountWebView() {
|
||||
const messages: Message[] = [];
|
||||
frame = document.createElement("iframe");
|
||||
frame.style.width = "500px";
|
||||
frame.style.height = "300px";
|
||||
document.body.append(frame);
|
||||
const win = frame.contentWindow as TerminalFrameWindow;
|
||||
Object.assign(win, {
|
||||
ReactNativeWebView: { postMessage: (raw: string) => messages.push(JSON.parse(raw)) },
|
||||
});
|
||||
win.document.open();
|
||||
win.document.write(terminalEmulatorWebViewHtml);
|
||||
win.document.close();
|
||||
await expect.poll(() => messages.some((m) => m.type === "bridgeReady")).toBe(true);
|
||||
const send = (message: object) =>
|
||||
win.__PASEO_TERMINAL_WEBVIEW_RECEIVE__({ streamKey: "owned", ...message });
|
||||
send({
|
||||
type: "mount",
|
||||
initialSnapshot: null,
|
||||
scrollbackLines: 1000,
|
||||
theme: { background: "#ffffff", foreground: "#111111" },
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
swipeGesturesEnabled: false,
|
||||
});
|
||||
await expect
|
||||
.poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned"))
|
||||
.toBe(true);
|
||||
return { win, send, messages };
|
||||
}
|
||||
|
||||
test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => {
|
||||
const { win, send, messages } = await mountWebView();
|
||||
send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" });
|
||||
await expect
|
||||
.poll(() => {
|
||||
const b = win.__paseoTerminal?.buffer.active;
|
||||
return b
|
||||
? Array.from({ length: b.length }, (_, i) => b.getLine(i)?.translateToString(true)).join(
|
||||
"\n",
|
||||
)
|
||||
: "";
|
||||
})
|
||||
.toContain("last A.B");
|
||||
send({ type: "findWidgetSize", size: { width: 356, height: 58 } });
|
||||
send({ type: "find", query: "a.b" });
|
||||
await expect
|
||||
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
|
||||
.toMatchObject({ resultCount: 2, resultIndex: 1 });
|
||||
send({ type: "find", query: "a.b", direction: "previous" });
|
||||
await expect
|
||||
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
|
||||
.toMatchObject({ resultCount: 2, resultIndex: 0 });
|
||||
send({ type: "find", streamKey: "stale", query: "absent" });
|
||||
expect(win.__paseoTerminal!.getSelection()).toBe("a.b");
|
||||
send({ type: "clearFind" });
|
||||
await expect
|
||||
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
|
||||
.toMatchObject({ resultCount: 0 });
|
||||
expect(messages.filter((m) => m.type === "input")).toEqual([]);
|
||||
send({ type: "unmount" });
|
||||
});
|
||||
|
||||
test("the generated WebView forwards file-only pastes to native as file bytes", async () => {
|
||||
const { win, send, messages } = await mountWebView();
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(
|
||||
new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }),
|
||||
);
|
||||
|
||||
win.document
|
||||
.querySelector(".xterm-helper-textarea")!
|
||||
.dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }));
|
||||
|
||||
await expect
|
||||
.poll(() => messages.find((m) => m.type === "filePaste"))
|
||||
.toEqual({
|
||||
type: "filePaste",
|
||||
streamKey: "owned",
|
||||
fileName: "image.png",
|
||||
mimeType: "image/png",
|
||||
data: "iVBORw==",
|
||||
});
|
||||
expect(messages.filter((m) => m.type === "input")).toEqual([]);
|
||||
send({ type: "unmount" });
|
||||
});
|
||||
@@ -1589,19 +1589,15 @@ const x = 1;
|
||||
it("warms shortstat cache in the background without blocking listing callers", async () => {
|
||||
expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined();
|
||||
|
||||
warmCheckoutShortstatInBackground(repoDir);
|
||||
const warmed = new Promise<void>((resolve) => {
|
||||
warmCheckoutShortstatInBackground(repoDir, undefined, resolve);
|
||||
});
|
||||
// The warm hands off to git and returns; listing callers see no value yet.
|
||||
expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined();
|
||||
|
||||
await warmed;
|
||||
// A repo with no origin/main computes to null, but null should still be cached.
|
||||
for (let attempts = 0; attempts < 20; attempts += 1) {
|
||||
const cached = getCachedCheckoutShortstat(repoDir);
|
||||
if (cached !== undefined) {
|
||||
expect(cached).toBeNull();
|
||||
return;
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
|
||||
throw new Error("shortstat background warm did not populate cache in time");
|
||||
expect(getCachedCheckoutShortstat(repoDir)).toBeNull();
|
||||
});
|
||||
|
||||
it("commits messages with quotes safely", async () => {
|
||||
|
||||
Reference in New Issue
Block a user