diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099f9efc0b..e632cb4b96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index 8f297dfb17..d5a0071189 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -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 { @@ -127,8 +133,7 @@ async function dropFiles(page: Page, files: DroppedFile[]): Promise { /** 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"); } diff --git a/packages/app/package.json b/packages/app/package.json index 5eaea08838..bc992b5d6c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/scripts/build-terminal-webview-html.mjs b/packages/app/scripts/build-terminal-webview-html.mjs deleted file mode 100644 index 238b2fd6fe..0000000000 --- a/packages/app/scripts/build-terminal-webview-html.mjs +++ /dev/null @@ -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 = ` - - - - - - - - -`; - -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)`); -} diff --git a/packages/app/src/components/terminal-emulator-webview.native.tsx b/packages/app/src/components/terminal-emulator-webview.native.tsx deleted file mode 100644 index 163e335381..0000000000 --- a/packages/app/src/components/terminal-emulator-webview.native.tsx +++ /dev/null @@ -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; -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(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([]); - const outputDecoderRef = useRef(new TextDecoder()); - const mountRequestedStreamKeyRef = useRef(null); - const rendererReadyStreamKeyRef = useRef(null); - const bridgeReadyTimeoutRef = useRef | null>(null); - const rendererReadyTimeoutRef = useRef | null>(null); - const pendingTapRef = useRef(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) => { - 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, - ) => { - 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>(() => { - bridgeReadyRef.current = false; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - scheduleBridgeReadyWatchdog(); - }, [scheduleBridgeReadyWatchdog]); - - const handleContentProcessDidTerminate = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleRenderProcessGone = useCallback< - NonNullable - >(() => { - 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>( - () => [styles.root, { backgroundColor }], - [backgroundColor], - ); - const webViewStyle = useMemo>( - () => [styles.webView, { backgroundColor }], - [backgroundColor], - ); - const webViewContainerStyle = useMemo>( - () => [styles.webViewContainer, { backgroundColor }], - [backgroundColor], - ); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - minHeight: 0, - minWidth: 0, - overflow: "hidden", - }, - webView: { - flex: 1, - }, - webViewContainer: { - flex: 1, - }, -}); diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index 052d7a02f0..0635291c4c 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -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 ; } - const Renderer = - rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator; - - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/terminal-webview-message-routing.test.ts b/packages/app/src/components/terminal-webview-message-routing.test.ts deleted file mode 100644 index 7a08aa19d9..0000000000 --- a/packages/app/src/components/terminal-webview-message-routing.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/components/terminal-webview-message-routing.ts b/packages/app/src/components/terminal-webview-message-routing.ts deleted file mode 100644 index c23c6b7244..0000000000 --- a/packages/app/src/components/terminal-webview-message-routing.ts +++ /dev/null @@ -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) - ); -} diff --git a/packages/app/src/components/terminal-webview-readiness.test.ts b/packages/app/src/components/terminal-webview-readiness.test.ts deleted file mode 100644 index 00db851676..0000000000 --- a/packages/app/src/components/terminal-webview-readiness.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/components/terminal-webview-readiness.ts b/packages/app/src/components/terminal-webview-readiness.ts deleted file mode 100644 index a0cf804156..0000000000 --- a/packages/app/src/components/terminal-webview-readiness.ts +++ /dev/null @@ -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 - ); -} diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 323868280f..dadb912a24 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -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({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index d3b918755f..4fe08bb6d5 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -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) diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index c837815a7c..18ba3f041e 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -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"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c3ee235c29..e28613bbff 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2332,11 +2332,6 @@ export const ar: TranslationResources = { }, diagnostics: { title: "التشخيص", - legacyTerminalRenderer: { - label: "استخدام عارض الطرفية السابق", - description: "استخدام طرفية WebView السابقة بعد إعادة فتح الطرفية", - accessibilityLabel: "استخدام عارض الطرفية السابق", - }, testAudio: "اختبار الصوت", playTest: "لعب الاختبار", playing: "جارٍ اللعب...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6649bd8bde..e056fdc855 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 81dd72cec0..6d5f422e3a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 52d8a9206a..3159dd4b61 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 0dd14ea5a3..1fcbbc7ad6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2316,11 +2316,6 @@ export const ja: TranslationResources = { }, diagnostics: { title: "診断", - legacyTerminalRenderer: { - label: "以前のターミナルレンダラーを使用", - description: "ターミナルを開き直した後、以前の WebView ターミナルを使用します", - accessibilityLabel: "以前のターミナルレンダラーを使用", - }, testAudio: "音声をテスト", playTest: "テスト再生", playing: "再生中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 0bfb1be599..ea823a94ce 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2307,11 +2307,6 @@ export const ko: TranslationResources = { }, diagnostics: { title: "진단", - legacyTerminalRenderer: { - label: "레거시 터미널 렌더러 사용", - description: "터미널을 다시 열 때 이전 WebView 터미널을 사용합니다.", - accessibilityLabel: "레거시 터미널 렌더러 사용", - }, testAudio: "오디오 테스트", playTest: "테스트 재생", playing: "재생 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 295c1acfaf..de85b202c8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 36dd91a9c5..5e70b0c1f4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2333,11 +2333,6 @@ export const ru: TranslationResources = { }, diagnostics: { title: "Диагностика", - legacyTerminalRenderer: { - label: "Использовать прежний рендерер терминала", - description: "Использовать прежний WebView-терминал после повторного открытия", - accessibilityLabel: "Использовать прежний рендерер терминала", - }, testAudio: "Проверка звука", playTest: "Воспроизвести тестовый звук", playing: "Воспроизведение...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7afb29ca30..96758eaca7 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2271,11 +2271,6 @@ export const zhCN: TranslationResources = { }, diagnostics: { title: "诊断", - legacyTerminalRenderer: { - label: "使用旧版终端渲染器", - description: "重新打开终端后使用之前的 WebView 终端", - accessibilityLabel: "使用旧版终端渲染器", - }, testAudio: "测试音频", playTest: "播放测试", playing: "正在播放...", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 3e3a4ef553..b90f5ccd24 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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; 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 ( - {isNative ? ( - - - - {t("settings.diagnostics.legacyTerminalRenderer.label")} - - - {t("settings.diagnostics.legacyTerminalRenderer.description")} - - - - - ) : null} {t("settings.diagnostics.app.rowTitle")} @@ -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 ( { 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", + ); }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts index 5c5c7ca608..8e4b08baf1 100644 --- a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts +++ b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts @@ -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"; } diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts deleted file mode 100644 index d1825d7eb7..0000000000 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ /dev/null @@ -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 = 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): void { - this.filePastes = this.filePastes.then(() => this.sendFilePaste(streamKey, file)); - } - - private async sendFilePaste( - streamKey: string, - file: Promise, - ): Promise { - 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 { - 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" }); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts deleted file mode 100644 index e7a9cfd8d1..0000000000 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Generated by packages/app/scripts/build-terminal-webview-html.mjs. -// Do not edit by hand. - -export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/terminal/webview/terminal-find.browser.test.ts b/packages/app/src/terminal/webview/terminal-find.browser.test.ts deleted file mode 100644 index 24176331d2..0000000000 --- a/packages/app/src/terminal/webview/terminal-find.browser.test.ts +++ /dev/null @@ -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" }); -}); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 0ef41b3b6f..ff13dc2ad8 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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((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 () => {