feat(app): route Electron browser find through main-process owner WebContents

This commit is contained in:
Mohamed Boudra
2026-05-31 12:45:29 +07:00
parent 40d6a9127a
commit 526e6070f8
6 changed files with 941 additions and 10 deletions
@@ -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<void>>(),
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<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onFocus,
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
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<typeof vi.fn<(text: string, options?: unknown) => number>>;
stopFindInPage: ReturnType<typeof vi.fn<(action: string) => void>>;
getURL: ReturnType<typeof vi.fn<() => string>>;
canGoBack: ReturnType<typeof vi.fn<() => boolean>>;
canGoForward: ReturnType<typeof vi.fn<() => boolean>>;
reload: ReturnType<typeof vi.fn<() => void>>;
stop: ReturnType<typeof vi.fn<() => 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(
<PaneProvider value={paneContextValue}>
<PaneFocusProvider
value={createPaneFocusContextValue({
isPaneFocused: input?.isInteractive ?? true,
isWorkspaceFocused: true,
})}
>
<BrowserPane
browserId="browser-a"
serverId="server-a"
workspaceId="workspace-a"
cwd="/repo"
isInteractive={input?.isInteractive ?? true}
/>
</PaneFocusProvider>
</PaneProvider>,
);
});
}
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,
});
});
});
@@ -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<void>;
getURL?: () => string;
executeJavaScript?: (code: string) => Promise<unknown>;
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<BrowserElementAttachment, "formatted"> & {
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<string | null>(null);
const domReadyRef = useRef(false);
const browserFindQueryRef = useRef("");
const browserFindGenerationRef = useRef(0);
const activeBrowserFindRef = useRef<ActiveBrowserFind | null>(null);
const browserFindOpenRef = useRef(false);
const [selectorActive, setSelectorActive] = useState(false);
const [browserFindMatchState, setBrowserFindMatchState] =
useState<PaneFindMatchState>(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 (
<View style={styles.container}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
<Pressable
+25
View File
@@ -92,10 +92,35 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url";
}
export interface DesktopBrowserFindOptions {
forward?: boolean;
findNext?: boolean;
matchCase?: boolean;
}
export type DesktopBrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
export interface DesktopBrowserFoundInPageResult {
requestId?: number;
activeMatchOrdinal?: number;
matches?: number;
finalUpdate?: boolean;
}
export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
findInPage?: (
browserId: string,
text: string,
options?: DesktopBrowserFindOptions,
) => Promise<number | null> | number | null;
stopFindInPage?: (browserId: string, action: DesktopBrowserFindAction) => Promise<void> | void;
onFoundInPage?: (
browserId: string,
listener: (result: DesktopBrowserFoundInPageResult) => void,
) => Promise<() => void> | (() => void);
}
export interface DesktopInvokeBridge {
@@ -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<number, string>();
const webContentsIdsByBrowserId = new Map<string, number>();
const ownerWebContentsIdsByBrowserId = new Map<string, number>();
const activeFindBrowserIdsByOwnerWebContentsId = new Map<number, string>();
const ownerFoundInPageListenerWebContentsIds = new Set<number>();
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 {
+45 -1
View File
@@ -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<void> {
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,
+34
View File
@@ -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);
};
},
},
});