mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): port find-in-page bridge to TypeScript Electron
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🪟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<webContentsId, () => 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
This commit is contained in:
parent
9ca33680ea
commit
7bbb063c71
4 changed files with 208 additions and 0 deletions
125
apps/desktop/electron/find-in-page.ts
Normal file
125
apps/desktop/electron/find-in-page.ts
Normal file
|
|
@ -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<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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<number, () => 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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
13
apps/desktop/src/global.d.ts
vendored
13
apps/desktop/src/global.d.ts
vendored
|
|
@ -237,6 +237,19 @@ declare global {
|
|||
// returns the most-installed themes.
|
||||
searchMarketplace: (query: string) => Promise<DesktopMarketplaceSearchItem[]>
|
||||
}
|
||||
// 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<void>
|
||||
onFoundInPage: (
|
||||
callback: (result: { activeMatchOrdinal: number; count: number }) => void
|
||||
) => () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue