mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
feat(app): route Electron browser find through main-process owner WebContents
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user