From 7bbb063c718f0821e3c34cdd5d76c66db4dea175 Mon Sep 17 00:00:00 2001 From: David Metcalfe Date: Thu, 16 Jul 2026 22:12:30 -0700 Subject: [PATCH] feat(desktop): port find-in-page bridge to TypeScript Electron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original PR targeted the CJS Electron files (apps/desktop/electron/main.cjs and preload.cjs), but commit 39d09453f "feat(desktop): ts-ify everything" renamed them to main.ts and preload.ts on current main. The PR's diff therefore targeted files that no longer exist on main. Brings the bridge forward to the current TypeScript Electron files and extracts the IPC bridge helpers into a focused pure-helpers module: - apps/desktop/electron/find-in-page.ts (new): - performFind(webContents, query, options) — wraps webContents.findInPage with default-coercing options. - stopFind(webContents, action) — clears highlights. - formatFoundInPage(result) — pure projection of Electron's FoundInPageResult onto the wire payload shape ({ activeMatchOrdinal, count }). - installFoundInPageForwarder(webContents) — wires a sender-scoped 'found-in-page' forwarder; returns an uninstall function. Returns a no-op uninstall for null or destroyed webContents so callers don't need guards. - apps/desktop/electron/main.ts: - ipcMain.handle('hermes:find-in-page', event => ...) resolves the requesting window via BrowserWindow.fromWebContents(event.sender) and routes the search to THAT window, not the global primary. This fixes a multi-window bug where Cmd+F pressed in a secondary session window (one per chat, spawned via hermes:window:openSession) searched the primary window instead of the focused surface. - ipcMain.handle('hermes:stop-find-in-page', event => ...) routes stopFind through the requesting window for multi-window correctness. - A per-sender lazy forwarder registry (foundInPageForwarders: Map void>) installs installFoundInPageForwarder on first findInPage call, scoped to the sender's webContents. Cleans up automatically via webContents.once('destroyed', ...). The forwarder sends results back to the SAME renderer that initiated the search, never the global primary — so a secondary session window's Cmd+F shows matches from THAT window and the match counter reports matches from THAT window's DOM. - apps/desktop/electron/preload.ts: - hermesDesktop.findInPage(query, options) — invokes the IPC handler. - hermesDesktop.stopFindInPage() — invokes the IPC handler. - hermesDesktop.onFoundInPage(callback) — subscribes to 'hermes:found-in-page' results from the sender; returns an unsubscribe function so the FindBar can clean up on unmount. - apps/desktop/src/global.d.ts: - Three new hermesDesktop method declarations: findInPage, stopFindInPage, onFoundInPage. The new forwarder install registers a 'found-in-page' listener bound to the sender's webContents and emits 'hermes:found-in-page' results back to the sender. The multi-window fix is part of the same port — the old PR's behavior (Cmd+F in a secondary session window searched the global primary) was a bug present in the CJS files, not a design constraint we wanted to preserve. The new helper module uses event.sender by design, so the multi-window correctness lands with the TS port. Fixes #46169 --- apps/desktop/electron/find-in-page.ts | 125 ++++++++++++++++++++++++++ apps/desktop/electron/main.ts | 56 ++++++++++++ apps/desktop/electron/preload.ts | 14 +++ apps/desktop/src/global.d.ts | 13 +++ 4 files changed, 208 insertions(+) create mode 100644 apps/desktop/electron/find-in-page.ts diff --git a/apps/desktop/electron/find-in-page.ts b/apps/desktop/electron/find-in-page.ts new file mode 100644 index 00000000000..d08e3da4e34 --- /dev/null +++ b/apps/desktop/electron/find-in-page.ts @@ -0,0 +1,125 @@ +/** + * Pure helpers for the desktop find-in-page bridge (Ctrl/Cmd+F). + * + * The renderer drives an Electron `webContents.findInPage` over IPC so it can + * reuse the native "find-in-page" experience (incremental search, match + * highlight, Enter to step, Shift+Enter to step backwards, Escape to clear) + * across chat transcripts and editor panels. Everything in this module is + * pure with respect to its inputs so the routing + payload shaping can be + * unit-tested without booting a BrowserWindow. + * + * Multi-window correctness: the IPC handlers in main.ts resolve the + * requesting window via `BrowserWindow.fromWebContents(event.sender)` so a + * Cmd+F pressed in a secondary session window searches THAT window, not the + * primary. The `found-in-page` results are forwarded back to the same sender + * — see {@link installFoundInPageForwarder}. + */ + +/** Match options accepted by the renderer's `findInPage` bridge call. */ +export interface FindInPageOptions { + /** Step direction. Defaults to `true` (forward). */ + forward?: boolean + /** + * `true` to advance to the next/previous match using the previous query; + * `false` to (re)search the current `query` from scratch. The renderer + * passes `false` on a fresh query and `true` on Enter / Shift+Enter. + */ + findNext?: boolean +} + +/** Payload shape sent back to the renderer on every `found-in-page` event. */ +export interface FoundInPagePayload { + /** 1-indexed ordinal of the active match, or 0 when none. */ + activeMatchOrdinal: number + /** Total matches in the document for the current query. */ + count: number +} + +/** + * Defensive projection of Electron's `found-in-page` event result. Electron + * exposes more fields (finalUpdate, selectionArea, etc.) that we don't need; + * keeping the projection explicit makes the wire shape auditable and keeps + * tests independent of the runtime type. + */ +export function formatFoundInPage(result: { + activeMatchOrdinal?: number + matches?: number +}): FoundInPagePayload { + return { + activeMatchOrdinal: Number(result?.activeMatchOrdinal ?? 0), + count: Number(result?.matches ?? 0) + } +} + +/** + * Issue a `findInPage` against the given `webContents`. No-op when the + * webContents is missing or destroyed — surfaces as a silent miss rather + * than throwing across the IPC boundary, matching Electron's own semantics + * for a destroyed renderer. + */ +export function performFind( + webContents: Electron.WebContents | null | undefined, + query: string, + options: FindInPageOptions | null | undefined +): void { + if (!webContents || webContents.isDestroyed()) { + return + } + + const opts = options && typeof options === 'object' ? options : {} + + webContents.findInPage(String(query ?? ''), { + forward: opts.forward !== false, + findNext: Boolean(opts.findNext) + }) +} + +/** + * Stop the current find and clear highlights. The default `action` matches + * what the renderer sends on Escape / close. + */ +export function stopFind( + webContents: Electron.WebContents | null | undefined, + action: 'clearSelection' | 'keepSelection' | 'activateSelection' = 'clearSelection' +): void { + if (!webContents || webContents.isDestroyed()) { + return + } + + webContents.stopFindInPage(action) +} + +/** + * Install a `found-in-page` listener on the given sender `webContents` and + * forward each result back to the SAME renderer (via `webContents.send`). + * + * Returns an uninstall function. Call it from `webContents.on('destroyed', …)` + * to avoid leaking the listener when the window goes away — Electron does + * not auto-detach webContents listeners on close. + * + * The forwarder is intentionally bound to a single sender rather than the + * primary window: a Cmd+F pressed in a secondary session window must + * highlight matches in THAT window, and the match counter must reflect + * THAT window's DOM, not the primary's. + */ +export function installFoundInPageForwarder( + webContents: Electron.WebContents | null | undefined +): () => void { + if (!webContents || webContents.isDestroyed()) { + return () => {} + } + + const handler = (_event: Electron.Event, result: Parameters[0]) => { + if (webContents.isDestroyed()) { + return + } + + webContents.send('hermes:found-in-page', formatFoundInPage(result)) + } + + webContents.on('found-in-page', handler) + + return () => { + webContents.off('found-in-page', handler) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 13d8a9f7891..8823ec1ef93 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -81,6 +81,7 @@ import { import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' import { findGitBash as _findGitBash } from './find-git-bash' +import { installFoundInPageForwarder, performFind, stopFind } from './find-in-page' import { createFirstRunSetupGate } from './first-run-setup-gate' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' @@ -9959,6 +9960,61 @@ ipcMain.handle('hermes:openExternal', (_event, url) => { } }) +// ── Find-in-page (Ctrl/Cmd+F) ───────────────────────────────────────────── +// The desktop supports multiple BrowserWindows (one primary plus any +// per-session secondary windows spawned via `hermes:window:openSession`). +// Find must run against the requesting window, not a global — otherwise +// Cmd+F pressed in a secondary session window would search the primary +// and the match counter would report matches the user can't see. Resolve +// the sender through `BrowserWindow.fromWebContents(event.sender)` and +// forward `found-in-page` results back to that same sender. + +// Lazily-installed forwarder per sender webContents. We track one +// uninstall fn per webContents id and prune entries when the sender goes +// away — Electron does not auto-detach webContents listeners on close, +// so the map is the cleanup path. +const foundInPageForwarders = new Map void>() + +function ensureFoundInPageForwarder(sender: Electron.WebContents): void { + if (foundInPageForwarders.has(sender.id)) { + return + } + + const uninstall = installFoundInPageForwarder(sender) + foundInPageForwarders.set(sender.id, uninstall) + + sender.once('destroyed', () => { + foundInPageForwarders.get(sender.id)?.() + foundInPageForwarders.delete(sender.id) + }) +} + +ipcMain.handle('hermes:find-in-page', (event, query, options) => { + const win = BrowserWindow.fromWebContents(event.sender) + + if (!win || win.isDestroyed()) { + return { count: 0 } + } + + ensureFoundInPageForwarder(event.sender) + performFind(win.webContents, query, options) + + // The match count arrives asynchronously via `found-in-page`; the + // synchronous return value is intentionally `{ count: 0 }` to mirror + // Electron's own `findInPage` return semantics (an opaque request id). + return { count: 0 } +}) + +ipcMain.handle('hermes:stop-find-in-page', event => { + const win = BrowserWindow.fromWebContents(event.sender) + + if (!win || win.isDestroyed()) { + return + } + + stopFind(win.webContents) +}) + ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => { if (!(await openPreviewInBrowser(url))) { throw new Error('Invalid preview URL') diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 47d854a1938..7cb17e5f4ef 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -268,5 +268,19 @@ contextBridge.exposeInMainWorld('hermesDesktop', { themes: { fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id), searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query) + }, + // Find-in-page (Ctrl/Cmd+F): delegates to Electron's + // webContents.findInPage on the IPC sender's window so a Cmd+F pressed + // in a secondary session window searches THAT window, not the primary. + // `onFoundInPage` returns the unsubscribe fn; the renderer wires it via + // `initFindInPageListener` in store/find-in-page.ts and tears it down + // when the FindBar unmounts. + findInPage: (query, options) => ipcRenderer.invoke('hermes:find-in-page', query, options), + stopFindInPage: () => ipcRenderer.invoke('hermes:stop-find-in-page'), + onFoundInPage: callback => { + const listener = (_event, result) => callback(result) + ipcRenderer.on('hermes:found-in-page', listener) + + return () => ipcRenderer.removeListener('hermes:found-in-page', listener) } }) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c0c3c7f246f..08da0f744b2 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -237,6 +237,19 @@ declare global { // returns the most-installed themes. searchMarketplace: (query: string) => Promise } + // Find-in-page: delegates to Electron's webContents.findInPage on the + // IPC sender's window so Cmd+F from a secondary session window + // searches that window (not the primary). `onFoundInPage` returns the + // unsubscribe fn; the renderer wires it via `initFindInPageListener` + // in store/find-in-page.ts and tears it down when the FindBar unmounts. + findInPage: ( + query: string, + options?: { forward?: boolean; findNext?: boolean } + ) => Promise<{ count: number }> + stopFindInPage: () => Promise + onFoundInPage: ( + callback: (result: { activeMatchOrdinal: number; count: number }) => void + ) => () => void } } }