From 526e6070f806a3cc173772d5707fbe8f4a448139 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 2 May 2026 23:16:19 +0700 Subject: [PATCH] feat(app): route Electron browser find through main-process owner WebContents --- .../components/browser-pane.electron.test.tsx | 503 ++++++++++++++++++ .../src/components/browser-pane.electron.tsx | 250 ++++++++- packages/app/src/desktop/host.ts | 25 + .../desktop/src/features/browser-webviews.ts | 93 +++- packages/desktop/src/main.ts | 46 +- packages/desktop/src/preload.ts | 34 ++ 6 files changed, 941 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/components/browser-pane.electron.test.tsx diff --git a/packages/app/src/components/browser-pane.electron.test.tsx b/packages/app/src/components/browser-pane.electron.test.tsx new file mode 100644 index 0000000000..a5b0dd3176 --- /dev/null +++ b/packages/app/src/components/browser-pane.electron.test.tsx @@ -0,0 +1,503 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserPane } from "@/components/browser-pane.electron"; +import { + PaneFocusProvider, + PaneProvider, + createPaneFocusContextValue, + type PaneContextValue, +} from "@/panels/pane-context"; +import { + createPaneFindPaneId, + handlePaneFindKeyboardAction, + setActivePaneFindPaneId, +} from "@/panels/pane-find-registry"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +const updateBrowser = vi.fn(); +let browserState = { + browsersById: { + "browser-a": { + id: "browser-a", + url: "https://example.com", + title: "", + faviconUrl: null, + canGoBack: false, + canGoForward: false, + isLoading: false, + lastError: null, + }, + }, + updateBrowser, +}; + +const { theme } = vi.hoisted(() => ({ + theme: { + borderRadius: { md: 6 }, + colors: { + accent: "#3b82f6", + border: "#333", + foreground: "#fff", + foregroundMuted: "#aaa", + palette: { red: { 500: "#ef4444" } }, + surface0: "#111", + surface1: "#222", + surface2: "#333", + }, + fontSize: { sm: 13, xs: 11 }, + spacing: { 1: 4, 2: 8 }, + }, +})); + +const desktopBridge = vi.hoisted(() => { + const foundInPageListeners = new Set<(result: unknown) => void>(); + return { + foundInPageListeners, + findInPage: vi.fn<(browserId: string, text: string, options?: unknown) => number>(), + setActivePane: vi.fn<(browserId: string | null) => Promise>(), + stopFindInPage: vi.fn<(browserId: string, action: string) => void>(), + onFoundInPage: vi.fn((_browserId: string, listener: (result: unknown) => void) => { + const scopedListener = (result: unknown) => listener(result); + foundInPageListeners.add(scopedListener); + return () => { + foundInPageListeners.delete(scopedListener); + }; + }), + eventsOn: vi.fn(), + }; +}); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => + function Icon() { + return React.createElement("span", { "data-icon": name }); + }; + + return { + ArrowLeft: createIcon("ArrowLeft"), + ArrowRight: createIcon("ArrowRight"), + ChevronDown: createIcon("ChevronDown"), + ChevronUp: createIcon("ChevronUp"), + MousePointer2: createIcon("MousePointer2"), + RotateCw: createIcon("RotateCw"), + X: createIcon("X"), + }; +}); + +vi.mock("react-native", () => { + const MockView = ({ + children, + testID, + style, + }: { + children?: React.ReactNode; + testID?: string; + style?: unknown; + }) => React.createElement("div", { "data-testid": testID, style }, children); + const MockText = ({ children }: { children?: React.ReactNode }) => + React.createElement("span", null, children); + const MockPressable = ({ + children, + disabled, + onPress, + testID, + }: { + children?: React.ReactNode; + disabled?: boolean; + onPress?: () => void; + testID?: string; + }) => + React.createElement( + "button", + { + "data-testid": testID, + disabled, + onClick: () => { + if (!disabled) onPress?.(); + }, + type: "button", + }, + children, + ); + const MockTextInput = React.forwardRef< + HTMLInputElement, + { + value?: string; + onChangeText?: (text: string) => void; + onFocus?: () => void; + onKeyPress?: (event: { + nativeEvent: { key: string; shiftKey?: boolean }; + preventDefault: () => void; + }) => void; + onSubmitEditing?: () => void; + testID?: string; + placeholder?: string; + } + >(function TextInput( + { value, onChangeText, onFocus, onKeyPress, onSubmitEditing, testID, placeholder }, + ref, + ) { + return React.createElement("input", { + "data-testid": testID, + onChange: (event: React.ChangeEvent) => + onChangeText?.(event.currentTarget.value), + onFocus, + onKeyDown: (event: React.KeyboardEvent) => { + onKeyPress?.({ + nativeEvent: { key: event.key, shiftKey: event.shiftKey }, + preventDefault: () => event.preventDefault(), + }); + if (event.key === "Enter") { + onSubmitEditing?.(); + } + }, + placeholder, + ref, + value: value ?? "", + }); + }); + + return { Pressable: MockPressable, Text: MockText, TextInput: MockTextInput, View: MockView }; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + hairlineWidth: 1, + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/attachments/workspace-attachments-store", () => ({ + buildWorkspaceAttachmentScopeKey: () => "scope-a", + useWorkspaceAttachments: () => [], + useWorkspaceAttachmentsStore: ( + selector: (state: { setWorkspaceAttachments: () => void }) => unknown, + ) => selector({ setWorkspaceAttachments: vi.fn() }), +})); + +vi.mock("@/desktop/host", () => ({ + getDesktopHost: () => ({ + browser: { + findInPage: desktopBridge.findInPage, + onFoundInPage: desktopBridge.onFoundInPage, + setActivePane: desktopBridge.setActivePane, + stopFindInPage: desktopBridge.stopFindInPage, + }, + events: { on: desktopBridge.eventsOn }, + }), + isElectronRuntime: () => true, +})); + +vi.mock("@/constants/layout", () => ({ + WORKSPACE_SECONDARY_HEADER_HEIGHT: 36, +})); + +vi.mock("@/stores/browser-store", () => ({ + normalizeWorkspaceBrowserUrl: (url: string) => url, + useBrowserStore: (selector: (state: typeof browserState) => unknown) => selector(browserState), +})); + +type FakeWebview = HTMLDivElement & { + findInPage: ReturnType number>>; + stopFindInPage: ReturnType void>>; + getURL: ReturnType string>>; + canGoBack: ReturnType boolean>>; + canGoForward: ReturnType boolean>>; + reload: ReturnType void>>; + stop: ReturnType void>>; +}; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; +let webview: FakeWebview | null = null; +let nextRequestId = 1; +let restoreCreateElement: (() => void) | null = null; + +const paneInstanceId = createPaneFindPaneId({ + serverId: "server-a", + workspaceId: "workspace-a", + paneId: "pane-a", +}); + +const paneContextValue: PaneContextValue = { + serverId: "server-a", + workspaceId: "workspace-a", + paneInstanceId, + tabId: "browser_browser-a", + target: { kind: "browser", browserId: "browser-a" }, + openTab: vi.fn(), + closeCurrentTab: vi.fn(), + retargetCurrentTab: vi.fn(), + openFileInWorkspace: vi.fn(), +}; + +function installWebviewElementFactory(): void { + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, "createElement"); + const createElement = ((tagName: string, options?: ElementCreationOptions) => { + if (tagName.toLowerCase() !== "webview") { + return originalCreateElement(tagName, options); + } + const element = originalCreateElement("div") as FakeWebview; + element.findInPage = vi.fn(() => nextRequestId++); + element.stopFindInPage = vi.fn(); + element.getURL = vi.fn(() => "https://example.com"); + element.canGoBack = vi.fn(() => false); + element.canGoForward = vi.fn(() => false); + element.reload = vi.fn(); + element.stop = vi.fn(); + webview = element; + return element; + }) as typeof document.createElement; + createElementSpy.mockImplementation(createElement); + restoreCreateElement = () => createElementSpy.mockRestore(); +} + +function renderBrowserPane(input?: { isInteractive?: boolean }): void { + act(() => { + root?.render( + + + + + , + ); + }); +} + +function openFind(): void { + act(() => { + setActivePaneFindPaneId(paneInstanceId); + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); +} + +function markWebviewDomReady(): void { + act(() => { + webview?.dispatchEvent(new Event("dom-ready")); + }); +} + +function inputElement(): HTMLInputElement { + const input = container?.querySelector('[data-testid="pane-find-input"]'); + expect(input).toBeInstanceOf(HTMLInputElement); + return input as HTMLInputElement; +} + +function changeInput(value: string): void { + const input = inputElement(); + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function pressKey(key: string, shiftKey = false): void { + const input = inputElement(); + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true })); + }); +} + +function click(testId: string): void { + const element = container?.querySelector(`[data-testid="${testId}"]`); + expect(element).toBeInstanceOf(HTMLElement); + act(() => { + element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function dispatchFoundInPage(result: { + requestId?: number; + activeMatchOrdinal: number; + matches: number; +}): void { + act(() => { + for (const listener of desktopBridge.foundInPageListeners) { + listener(result); + } + }); +} + +beforeEach(() => { + updateBrowser.mockClear(); + desktopBridge.findInPage.mockImplementation(() => nextRequestId++); + desktopBridge.findInPage.mockClear(); + desktopBridge.onFoundInPage.mockClear(); + desktopBridge.setActivePane.mockClear(); + desktopBridge.stopFindInPage.mockClear(); + desktopBridge.eventsOn.mockClear(); + desktopBridge.foundInPageListeners.clear(); + browserState = { + ...browserState, + browsersById: { + "browser-a": { + id: "browser-a", + url: "https://example.com", + title: "", + faviconUrl: null, + canGoBack: false, + canGoForward: false, + isLoading: false, + lastError: null, + }, + }, + }; + nextRequestId = 1; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + installWebviewElementFactory(); +}); + +afterEach(() => { + act(() => { + root?.unmount(); + }); + root = null; + container?.remove(); + container = null; + webview = null; + restoreCreateElement?.(); + restoreCreateElement = null; +}); + +describe("BrowserPane Electron find", () => { + it("registers browser find through the shared FindBar and desktop bridge find APIs", () => { + renderBrowserPane(); + markWebviewDomReady(); + openFind(); + + changeInput("needle"); + + expect(desktopBridge.onFoundInPage).toHaveBeenCalledWith("browser-a", expect.any(Function)); + expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", { + forward: true, + findNext: false, + matchCase: false, + }); + expect(container?.textContent).toContain("Searching..."); + dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 2, matches: 5 }); + expect(container?.textContent).toContain("2 / 5"); + + pressKey("Enter"); + expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", { + forward: true, + findNext: true, + matchCase: false, + }); + dispatchFoundInPage({ requestId: 2, activeMatchOrdinal: 3, matches: 5 }); + + pressKey("Enter", true); + expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", { + forward: false, + findNext: true, + matchCase: false, + }); + + click("pane-find-close"); + + expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection"); + expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull(); + }); + + it("ignores requestId-less found-in-page events while a newer requestId search is active", () => { + renderBrowserPane(); + markWebviewDomReady(); + openFind(); + + changeInput("needle"); + dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 1, matches: 2 }); + expect(container?.textContent).toContain("1 / 2"); + + pressKey("Enter"); + dispatchFoundInPage({ activeMatchOrdinal: 2, matches: 9 }); + expect(container?.textContent).toContain("Searching..."); + + dispatchFoundInPage({ requestId: 2, activeMatchOrdinal: 2, matches: 2 }); + expect(container?.textContent).toContain("2 / 2"); + }); + + it("cleans browser find selection on empty query, navigation, blur, and unmount", () => { + renderBrowserPane(); + markWebviewDomReady(); + openFind(); + changeInput("needle"); + dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 1, matches: 3 }); + + changeInput(""); + expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection"); + expect(container?.textContent).toContain("0 / 0"); + dispatchFoundInPage({ activeMatchOrdinal: 2, matches: 9 }); + expect(container?.textContent).toContain("0 / 0"); + + changeInput("needle"); + expect(container?.textContent).toContain("Searching..."); + act(() => { + webview?.dispatchEvent(new Event("did-start-loading")); + }); + expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection"); + expect(container?.textContent).toContain("0 / 0"); + + act(() => { + webview?.dispatchEvent(new Event("dom-ready")); + }); + expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", { + forward: true, + findNext: false, + matchCase: false, + }); + + renderBrowserPane({ isInteractive: false }); + expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection"); + + act(() => { + root?.unmount(); + }); + expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection"); + expect(desktopBridge.foundInPageListeners.size).toBe(0); + }); + + it("does not call webview find methods before dom-ready", () => { + renderBrowserPane(); + openFind(); + + changeInput("needle"); + expect(container?.textContent).toContain("Searching..."); + expect(desktopBridge.findInPage).not.toHaveBeenCalled(); + + click("pane-find-close"); + expect(desktopBridge.stopFindInPage).not.toHaveBeenCalled(); + + openFind(); + changeInput("needle"); + markWebviewDomReady(); + expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", { + forward: true, + findNext: false, + matchCase: false, + }); + }); +}); diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx index fe4a2bb2bb..7875a11feb 100644 --- a/packages/app/src/components/browser-pane.electron.tsx +++ b/packages/app/src/components/browser-pane.electron.tsx @@ -1,4 +1,4 @@ -import { +import React, { useCallback, useEffect, useMemo, @@ -20,11 +20,20 @@ import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { getDesktopHost, isElectronRuntime, + type DesktopBrowserFoundInPageResult, + type DesktopBrowserFindAction, type DesktopBrowserShortcutEvent, } from "@/desktop/host"; import { isDev } from "@/constants/platform"; +import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find"; import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store"; +interface ElectronFindOptions { + forward?: boolean; + findNext?: boolean; + matchCase?: boolean; +} + type ElectronWebview = HTMLElement & { canGoBack?: () => boolean; canGoForward?: () => boolean; @@ -35,11 +44,20 @@ type ElectronWebview = HTMLElement & { loadURL?: (url: string) => Promise; getURL?: () => string; executeJavaScript?: (code: string) => Promise; + findInPage?: (text: string, options?: ElectronFindOptions) => number; + stopFindInPage?: (action: "clearSelection" | "keepSelection" | "activateSelection") => void; focus?: () => void; addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; }; +interface ElectronFoundInPageResult extends DesktopBrowserFoundInPageResult { + requestId?: number; + activeMatchOrdinal?: number; + matches?: number; + finalUpdate?: boolean; +} + type WebTextInput = TextInput & { getNativeRef?: () => unknown; }; @@ -50,6 +68,15 @@ type BrowserElementSelection = Omit & { const ERR_ABORTED = -3; const ALLOWED_BROWSER_PROTOCOLS = new Set(["http:", "https:"]); +const EMPTY_FIND_MATCH_STATE: PaneFindMatchState = { status: "empty" }; +const NO_FIND_MATCH_STATE: PaneFindMatchState = { status: "no-match" }; +const PENDING_FIND_MATCH_STATE: PaneFindMatchState = { status: "pending" }; + +interface ActiveBrowserFind { + generation: number; + query: string; + requestId: number | null; +} function truncateText(value: string, maxLength: number): string { return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value; @@ -234,6 +261,27 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow return event.action === "focus-url"; } +function getFoundInPageResult(event: Event): ElectronFoundInPageResult | null { + const result = (event as Event & { result?: unknown }).result; + if (!result || typeof result !== "object") { + return null; + } + return result as ElectronFoundInPageResult; +} + +function stopBrowserFindInPage(input: { + browserId: string; + webview: ElectronWebview | null; + action: DesktopBrowserFindAction; +}): void { + const bridge = getDesktopHost()?.browser; + if (bridge?.stopFindInPage) { + void bridge.stopFindInPage(input.browserId, input.action); + return; + } + input.webview?.stopFindInPage?.(input.action); +} + function startSelectorResultPolling(input: { webview: ElectronWebview; onSelection: (selection: BrowserElementSelection) => void; @@ -295,7 +343,13 @@ export function BrowserPane({ browserRef.current = browser; const pendingNavigationUrlRef = useRef(null); const domReadyRef = useRef(false); + const browserFindQueryRef = useRef(""); + const browserFindGenerationRef = useRef(0); + const activeBrowserFindRef = useRef(null); + const browserFindOpenRef = useRef(false); const [selectorActive, setSelectorActive] = useState(false); + const [browserFindMatchState, setBrowserFindMatchState] = + useState(EMPTY_FIND_MATCH_STATE); const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com"); const workspaceAttachmentScopeKey = useMemo( () => buildBrowserAttachmentScopeKey({ cwd, serverId, workspaceId }), @@ -372,6 +426,130 @@ export function BrowserPane({ } }, []); + const clearBrowserFindSelection = useCallback((input?: { resetQuery?: boolean }) => { + if (input?.resetQuery) { + browserFindQueryRef.current = ""; + } + browserFindGenerationRef.current += 1; + activeBrowserFindRef.current = null; + setBrowserFindMatchState(EMPTY_FIND_MATCH_STATE); + if (domReadyRef.current) { + stopBrowserFindInPage({ + browserId: browserIdRef.current, + webview: webviewRef.current, + action: "clearSelection", + }); + } + }, []); + + const runBrowserFind = useCallback( + (query: string, direction: "next" | "previous", input?: { reset?: boolean }) => { + browserFindQueryRef.current = query; + if (!query) { + clearBrowserFindSelection({ resetQuery: true }); + return; + } + + const webview = webviewRef.current; + if (!domReadyRef.current) { + setBrowserFindMatchState(PENDING_FIND_MATCH_STATE); + return; + } + const bridgeFindInPage = getDesktopHost()?.browser?.findInPage; + if (!bridgeFindInPage && !webview?.findInPage) { + setBrowserFindMatchState(NO_FIND_MATCH_STATE); + return; + } + + const generation = (browserFindGenerationRef.current += 1); + activeBrowserFindRef.current = { + generation, + query, + requestId: null, + }; + setBrowserFindMatchState(PENDING_FIND_MATCH_STATE); + + const options = { + forward: direction === "next", + findNext: !input?.reset, + matchCase: false, + }; + const requestIdResult = bridgeFindInPage + ? bridgeFindInPage(browserIdRef.current, query, options) + : webview?.findInPage?.(query, options); + if (typeof requestIdResult === "number") { + activeBrowserFindRef.current = { + generation, + query, + requestId: requestIdResult, + }; + return; + } + void Promise.resolve(requestIdResult ?? null).then((requestId) => { + const activeFind = activeBrowserFindRef.current; + if ( + !activeFind || + activeFind.generation !== generation || + activeFind.query !== browserFindQueryRef.current + ) { + return undefined; + } + activeBrowserFindRef.current = { + ...activeFind, + requestId: typeof requestId === "number" ? requestId : null, + }; + return undefined; + }); + }, + [clearBrowserFindSelection], + ); + + const handleFoundInPageResult = useCallback((result: ElectronFoundInPageResult) => { + const activeFind = activeBrowserFindRef.current; + if ( + !activeFind || + activeFind.generation !== browserFindGenerationRef.current || + activeFind.query !== browserFindQueryRef.current + ) { + return; + } + const eventRequestId = typeof result.requestId === "number" ? result.requestId : null; + if (typeof activeFind.requestId === "number") { + if (eventRequestId !== activeFind.requestId) { + return; + } + } else if (eventRequestId !== null) { + return; + } + + const total = typeof result.matches === "number" ? Math.max(0, result.matches) : 0; + if (total === 0) { + setBrowserFindMatchState(NO_FIND_MATCH_STATE); + return; + } + + const current = + typeof result.activeMatchOrdinal === "number" && result.activeMatchOrdinal > 0 + ? result.activeMatchOrdinal + : 1; + setBrowserFindMatchState({ + status: "matched", + current: Math.min(current, total), + total, + }); + }, []); + + const handleFoundInPage = useCallback( + (event: Event) => { + const result = getFoundInPageResult(event); + if (!result) { + return; + } + handleFoundInPageResult(result); + }, + [handleFoundInPageResult], + ); + useEffect(() => { if (!isElectronRuntime()) { return; @@ -403,6 +581,7 @@ export function BrowserPane({ webview.style.background = "transparent"; const handleStartLoading = () => { + clearBrowserFindSelection(); updateBrowser(browserId, { isLoading: true, lastError: null }); syncNavigationState({ syncUrl: false }); }; @@ -429,6 +608,7 @@ export function BrowserPane({ syncNavigationState(); }; const handleWillNavigate = (event: Event) => { + clearBrowserFindSelection(); const nextUrl = typeof (event as Event & { url?: unknown }).url === "string" ? ((event as Event & { url?: string }).url ?? "") @@ -471,6 +651,9 @@ export function BrowserPane({ const handleDomReady = () => { domReadyRef.current = true; syncNavigationState(); + if (browserFindOpenRef.current && browserFindQueryRef.current) { + runBrowserFind(browserFindQueryRef.current, "next", { reset: true }); + } }; const handleWebviewFocus = () => { onFocusPane?.(); @@ -488,6 +671,29 @@ export function BrowserPane({ webview.addEventListener("focus", handleWebviewFocus); webview.addEventListener("mousedown", handleWebviewFocus); + const foundInPageBridge = getDesktopHost()?.browser?.onFoundInPage; + let unsubscribeFoundInPage: (() => void) | null = null; + let didCleanupFoundInPage = false; + if (foundInPageBridge) { + const unsubscribeResult = foundInPageBridge(browserId, (result) => { + handleFoundInPageResult(result); + }); + if (typeof unsubscribeResult === "function") { + unsubscribeFoundInPage = unsubscribeResult; + } else { + void Promise.resolve(unsubscribeResult).then((unsubscribe) => { + if (didCleanupFoundInPage) { + unsubscribe(); + return undefined; + } + unsubscribeFoundInPage = unsubscribe; + return undefined; + }); + } + } else { + webview.addEventListener("found-in-page", handleFoundInPage); + } + host.appendChild(webview); if (initialUnsafeNavigationMessage) { updateBrowserRef.current(browserIdRef.current, { @@ -506,8 +712,20 @@ export function BrowserPane({ webview.removeEventListener("page-favicon-updated", handleFaviconUpdated); webview.removeEventListener("did-fail-load", handleLoadFailed); webview.removeEventListener("dom-ready", handleDomReady); + didCleanupFoundInPage = true; + unsubscribeFoundInPage?.(); + if (!foundInPageBridge) { + webview.removeEventListener("found-in-page", handleFoundInPage); + } webview.removeEventListener("focus", handleWebviewFocus); webview.removeEventListener("mousedown", handleWebviewFocus); + if (domReadyRef.current) { + stopBrowserFindInPage({ + browserId: browserIdRef.current, + webview, + action: "clearSelection", + }); + } if (host.contains(webview)) { host.removeChild(webview); } @@ -519,6 +737,35 @@ export function BrowserPane({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [browserId, onFocusPane]); + const paneFind = usePaneFind({ + matchState: browserFindMatchState, + onQuery: (query) => runBrowserFind(query, "next", { reset: true }), + onNext: () => runBrowserFind(browserFindQueryRef.current, "next"), + onPrev: () => runBrowserFind(browserFindQueryRef.current, "previous"), + onClose: () => clearBrowserFindSelection({ resetQuery: true }), + }); + + useEffect(() => { + browserFindOpenRef.current = paneFind.isOpen; + if (!paneFind.isOpen) { + clearBrowserFindSelection({ resetQuery: true }); + } + }, [clearBrowserFindSelection, paneFind.isOpen]); + + useEffect(() => { + if (isInteractive) { + if (paneFind.isOpen && browserFindQueryRef.current) { + runBrowserFind(browserFindQueryRef.current, "next", { reset: true }); + } + return; + } + clearBrowserFindSelection(); + }, [clearBrowserFindSelection, isInteractive, paneFind.isOpen, runBrowserFind]); + + useEffect(() => { + return () => clearBrowserFindSelection({ resetQuery: true }); + }, [clearBrowserFindSelection]); + const navigate = useCallback((nextUrl: string) => { const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl); const webview = webviewRef.current; @@ -939,6 +1186,7 @@ export function BrowserPane({ return ( + {paneFind.isOpen ? : null} Promise; openDevTools?: (browserId: string) => Promise; clearPartition?: (browserId: string) => Promise; + findInPage?: ( + browserId: string, + text: string, + options?: DesktopBrowserFindOptions, + ) => Promise | number | null; + stopFindInPage?: (browserId: string, action: DesktopBrowserFindAction) => Promise | void; + onFoundInPage?: ( + browserId: string, + listener: (result: DesktopBrowserFoundInPageResult) => void, + ) => Promise<() => void> | (() => void); } export interface DesktopInvokeBridge { diff --git a/packages/desktop/src/features/browser-webviews.ts b/packages/desktop/src/features/browser-webviews.ts index f71cb5850d..849312d4e4 100644 --- a/packages/desktop/src/features/browser-webviews.ts +++ b/packages/desktop/src/features/browser-webviews.ts @@ -1,16 +1,71 @@ import { webContents as allWebContents, type WebContents } from "electron"; +export const BROWSER_FOUND_IN_PAGE_EVENT = "paseo:event:browser-found-in-page"; + const browserIdsByWebContentsId = new Map(); +const webContentsIdsByBrowserId = new Map(); +const ownerWebContentsIdsByBrowserId = new Map(); +const activeFindBrowserIdsByOwnerWebContentsId = new Map(); +const ownerFoundInPageListenerWebContentsIds = new Set(); let workspaceActiveBrowserId: string | null = null; export function listRegisteredPaseoBrowserIds(): string[] { return Array.from(new Set(browserIdsByWebContentsId.values())).sort(); } -export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void { +function ensureOwnerFoundInPageListener(ownerContents: WebContents): void { + if (ownerFoundInPageListenerWebContentsIds.has(ownerContents.id)) { + return; + } + ownerFoundInPageListenerWebContentsIds.add(ownerContents.id); + const handleFoundInPage = (_event: Electron.Event, result: Electron.Result): void => { + const browserId = activeFindBrowserIdsByOwnerWebContentsId.get(ownerContents.id); + if (!browserId || ownerContents.isDestroyed()) { + return; + } + ownerContents.send(BROWSER_FOUND_IN_PAGE_EVENT, { + browserId, + requestId: result.requestId, + activeMatchOrdinal: result.activeMatchOrdinal, + matches: result.matches, + finalUpdate: result.finalUpdate, + }); + if (result.finalUpdate) { + activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContents.id); + } + }; + ownerContents.on("found-in-page", handleFoundInPage); + ownerContents.once("destroyed", () => { + ownerContents.removeListener("found-in-page", handleFoundInPage); + ownerFoundInPageListenerWebContentsIds.delete(ownerContents.id); + activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContents.id); + }); +} + +export function registerPaseoBrowserWebContents( + contents: WebContents, + browserId: string, + ownerContents?: WebContents, +): void { browserIdsByWebContentsId.set(contents.id, browserId); + webContentsIdsByBrowserId.set(browserId, contents.id); + if (ownerContents && !ownerContents.isDestroyed()) { + ownerWebContentsIdsByBrowserId.set(browserId, ownerContents.id); + ensureOwnerFoundInPageListener(ownerContents); + } contents.once("destroyed", () => { browserIdsByWebContentsId.delete(contents.id); + if (webContentsIdsByBrowserId.get(browserId) === contents.id) { + webContentsIdsByBrowserId.delete(browserId); + const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId); + ownerWebContentsIdsByBrowserId.delete(browserId); + if ( + ownerContentsId && + activeFindBrowserIdsByOwnerWebContentsId.get(ownerContentsId) === browserId + ) { + activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContentsId); + } + } if (workspaceActiveBrowserId === browserId) { workspaceActiveBrowserId = null; } @@ -29,14 +84,36 @@ export function setWorkspaceActivePaseoBrowserId(browserId: string | null): void } export function getPaseoBrowserWebContents(browserId: string): WebContents | null { - for (const [contentsId, registeredBrowserId] of browserIdsByWebContentsId) { - if (registeredBrowserId !== browserId) continue; - const contents = allWebContents.fromId(contentsId); - if (contents && !contents.isDestroyed()) { - return contents; - } + const contentsId = webContentsIdsByBrowserId.get(browserId); + if (!contentsId) { + return null; + } + const contents = allWebContents.fromId(contentsId); + return contents && !contents.isDestroyed() ? contents : null; +} + +export function setActivePaseoBrowserFind(browserId: string): boolean { + const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId); + if (!ownerContentsId) { + return false; + } + const ownerContents = allWebContents.fromId(ownerContentsId); + if (!ownerContents || ownerContents.isDestroyed()) { + return false; + } + ensureOwnerFoundInPageListener(ownerContents); + activeFindBrowserIdsByOwnerWebContentsId.set(ownerContents.id, browserId); + return true; +} + +export function clearActivePaseoBrowserFind(browserId: string): void { + const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId); + if (!ownerContentsId) { + return; + } + if (activeFindBrowserIdsByOwnerWebContentsId.get(ownerContentsId) === browserId) { + activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContentsId); } - return null; } export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 1ff035e710..4787da15d0 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -33,10 +33,12 @@ import { import { registerOpenerHandlers } from "./features/opener.js"; import { setupApplicationMenu } from "./features/menu.js"; import { + clearActivePaseoBrowserFind, getPaseoBrowserIdForWebContents, getPaseoBrowserWebContents, listRegisteredPaseoBrowserIds, registerPaseoBrowserWebContents, + setActivePaseoBrowserFind, setWorkspaceActivePaseoBrowserId, } from "./features/browser-webviews.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; @@ -310,6 +312,48 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow await session.fromPartition(partition).clearStorageData(); }); +ipcMain.handle( + "paseo:browser:find-in-page", + (_event, browserId: unknown, text: unknown, options: unknown): number | null => { + if (typeof browserId !== "string" || typeof text !== "string" || text.length === 0) { + return null; + } + const contents = getPaseoBrowserWebContents(browserId); + if (!contents) { + return null; + } + setActivePaseoBrowserFind(browserId); + const inputOptions = + options && typeof options === "object" + ? (options as { forward?: unknown; findNext?: unknown; matchCase?: unknown }) + : {}; + return contents.findInPage(text, { + forward: inputOptions.forward !== false, + findNext: inputOptions.findNext === true, + matchCase: inputOptions.matchCase === true, + }); + }, +); + +ipcMain.handle( + "paseo:browser:stop-find-in-page", + (_event, browserId: unknown, action: unknown): null => { + if (typeof browserId !== "string") { + return null; + } + if ( + action !== "clearSelection" && + action !== "keepSelection" && + action !== "activateSelection" + ) { + return null; + } + getPaseoBrowserWebContents(browserId)?.stopFindInPage(action); + clearActivePaseoBrowserFind(browserId); + return null; + }, +); + protocol.registerSchemesAsPrivileged([ { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, ]); @@ -431,7 +475,7 @@ async function createMainWindow(): Promise { mainWindow.webContents.on("did-attach-webview", (_event, contents) => { const browserId = pendingBrowserWebviewIds.shift() ?? null; if (browserId) { - registerPaseoBrowserWebContents(contents, browserId); + registerPaseoBrowserWebContents(contents, browserId, mainWindow.webContents); log.info("[browser-webview] registered", { browserId, webContentsId: contents.id, diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 6999fa9c21..e1364d0d17 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,6 +1,14 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; type EventHandler = (payload: unknown) => void; +type BrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection"; + +interface BrowserFoundInPageResult { + requestId: number; + activeMatchOrdinal: number; + matches: number; + finalUpdate: boolean; +} contextBridge.exposeInMainWorld("paseoDesktop", { platform: process.platform, @@ -69,5 +77,31 @@ contextBridge.exposeInMainWorld("paseoDesktop", { ipcRenderer.invoke("paseo:browser:open-devtools", browserId), clearPartition: (browserId: string) => ipcRenderer.invoke("paseo:browser:clear-partition", browserId), + findInPage: ( + browserId: string, + text: string, + options?: { forward?: boolean; findNext?: boolean; matchCase?: boolean }, + ) => ipcRenderer.invoke("paseo:browser:find-in-page", browserId, text, options), + stopFindInPage: (browserId: string, action: BrowserFindAction) => + ipcRenderer.invoke("paseo:browser:stop-find-in-page", browserId, action), + onFoundInPage: ( + browserId: string, + listener: (result: BrowserFoundInPageResult) => void, + ): (() => void) => { + const ipcListener = ( + _ipcEvent: Electron.IpcRendererEvent, + payload: BrowserFoundInPageResult & { browserId?: unknown }, + ) => { + if (payload?.browserId !== browserId) { + return; + } + const { browserId: _browserId, ...result } = payload; + listener(result); + }; + ipcRenderer.on("paseo:event:browser-found-in-page", ipcListener); + return () => { + ipcRenderer.removeListener("paseo:event:browser-found-in-page", ipcListener); + }; + }, }, });