hermes-agent/apps/desktop/electron/find-in-page.ts

121 lines
4.3 KiB
TypeScript

/**
* 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<typeof formatFoundInPage>[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)
}
}