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 } } }