227 lines
6.9 KiB
TypeScript
227 lines
6.9 KiB
TypeScript
import { atom } from 'nanostores'
|
|
|
|
import { captureFindScope, currentFindScope, performScopedFind, releaseFindScope } from '@/lib/find-in-page-scope'
|
|
|
|
export interface FindInPageState {
|
|
active: boolean
|
|
query: string
|
|
matchOrdinal: number
|
|
matchCount: number
|
|
}
|
|
|
|
const EMPTY: FindInPageState = { active: false, query: '', matchOrdinal: 0, matchCount: 0 }
|
|
|
|
export const $findInPage = atom<FindInPageState>({ ...EMPTY })
|
|
|
|
/**
|
|
* Open the find bar and capture the CURRENT VIEW as the search scope.
|
|
*
|
|
* Capturing once at open time (rather than re-resolving on every keystroke)
|
|
* means a mid-search route change can't silently re-home the highlights onto
|
|
* a different session — the FindBar's `useLocation` cleanup closes the bar
|
|
* before the route flips, so the scope the user actually sees in the input
|
|
* is the only one ever searched. See apps/desktop/src/components/find-bar.tsx
|
|
* for the route-change close logic; see lib/find-in-page-scope.ts for the
|
|
* "current view" predicate (#81726).
|
|
*/
|
|
export function openFindBar(): void {
|
|
$findInPage.set({ ...EMPTY, active: true })
|
|
captureFindScope()
|
|
}
|
|
|
|
export function closeFindBar(): void {
|
|
// Already closed: don't re-issue clear. Escape is a shared gesture (the
|
|
// switcher and dialogs claim it too), so a stray second close must not
|
|
// re-strip highlights from a bar that has already been torn down.
|
|
if (!$findInPage.get().active) {
|
|
return
|
|
}
|
|
|
|
$findInPage.set({ ...EMPTY })
|
|
// Strip highlights and the scope marker from the DOM we previously wrapped.
|
|
releaseFindScope()
|
|
}
|
|
|
|
export async function setFindQuery(query: string): Promise<void> {
|
|
const prev = $findInPage.get()
|
|
|
|
// Never search for a closed bar. The component clears its debounce on
|
|
// close, but a timer that already fired (or any late caller) must not
|
|
// re-wrap matches after the user pressed Escape.
|
|
if (!prev.active) {
|
|
return
|
|
}
|
|
|
|
if (!query) {
|
|
$findInPage.set({ ...prev, query: '', matchOrdinal: 0, matchCount: 0 })
|
|
const scope = currentFindScope()
|
|
|
|
if (scope) {
|
|
// Re-run the scoped walker with an empty query — same code path,
|
|
// strips highlights + zeroes the counter without special-casing.
|
|
performScopedFind(scope, '', { forward: true, findNext: false })
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
const scope = currentFindScope()
|
|
|
|
if (!scope) {
|
|
// No chat surface to search (e.g. settings page, command center). The
|
|
// bar still accepts a query for parity with the bridge-driven path, but
|
|
// matches will be zero — there's nothing on screen that IS a "view".
|
|
$findInPage.set({ ...prev, query, matchOrdinal: 0, matchCount: 0 })
|
|
|
|
return
|
|
}
|
|
|
|
const result = performScopedFind(scope, query, { forward: true, findNext: false })
|
|
|
|
$findInPage.set({ ...prev, query, matchOrdinal: result.activeOrdinal, matchCount: result.count })
|
|
}
|
|
|
|
export function findNext(): void {
|
|
step(true)
|
|
}
|
|
|
|
export function findPrevious(): void {
|
|
step(false)
|
|
}
|
|
|
|
function step(forward: boolean): void {
|
|
const { query } = $findInPage.get()
|
|
|
|
if (!query) {
|
|
return
|
|
}
|
|
|
|
const scope = currentFindScope()
|
|
|
|
if (!scope) {
|
|
return
|
|
}
|
|
|
|
const result = performScopedFind(scope, query, { forward, findNext: true })
|
|
|
|
$findInPage.set({ ...$findInPage.get(), matchOrdinal: result.activeOrdinal, matchCount: result.count })
|
|
}
|
|
|
|
/** Called by the preload bridge when `found-in-page` fires on webContents.
|
|
* Retained for the multi-window case (a secondary session window still uses
|
|
* the Electron bridge — see electron/find-in-page.ts); the renderer-side
|
|
* walker for the primary window never fires this. */
|
|
export function updateFindResults(activeMatch: number, count: number): void {
|
|
const prev = $findInPage.get()
|
|
$findInPage.set({ ...prev, matchOrdinal: activeMatch, matchCount: count })
|
|
}
|
|
|
|
// The found-in-page subscription is process-wide, not per-mount: the FindBar
|
|
// lives in the global overlay set and the shell remounts it on a connection
|
|
// re-home (soft switch) while route changes keep it alive. Refcount the single
|
|
// bridge listener so a remount cannot stack duplicate subscriptions — every
|
|
// stacked listener would re-dispatch the same result and, worse, outlive its
|
|
// component.
|
|
let listenerRefs = 0
|
|
let detachListener: (() => void) | undefined
|
|
|
|
/**
|
|
* Subscribe to `found-in-page` results. Returns a release fn; the underlying
|
|
* bridge listener is installed on the first subscriber and removed when the
|
|
* last one releases. Safe to call from an effect with a `[]` dep list.
|
|
*
|
|
* Kept for secondary-window renderers that still drive search via the
|
|
* Electron bridge. The primary window's renderer-side walker calls
|
|
* `updateFindResults` synchronously and never wires this listener.
|
|
*/
|
|
export function initFindInPageListener(): () => void {
|
|
listenerRefs += 1
|
|
|
|
if (listenerRefs === 1) {
|
|
detachListener = window.hermesDesktop?.onFoundInPage?.(result => {
|
|
updateFindResults(result.activeMatchOrdinal, result.count)
|
|
})
|
|
}
|
|
|
|
let released = false
|
|
|
|
return () => {
|
|
// Guard double-release: React can invoke a cleanup once, but a caller
|
|
// holding the fn shouldn't be able to drive the refcount negative.
|
|
if (released) {
|
|
return
|
|
}
|
|
|
|
released = true
|
|
listenerRefs -= 1
|
|
|
|
if (listenerRefs === 0) {
|
|
detachListener?.()
|
|
detachListener = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Test seam: number of live bridge subscriptions (0 or 1 in practice). */
|
|
export function findInPageListenerCount(): number {
|
|
return listenerRefs
|
|
}
|
|
|
|
/**
|
|
* Test seam: force-detach the bridge listener and zero the refcount.
|
|
* Production code never calls this — tests use it so one case's leaked
|
|
* subscription can't bleed into the next.
|
|
*/
|
|
export function resetFindInPageListenerForTest(): void {
|
|
detachListener?.()
|
|
detachListener = undefined
|
|
listenerRefs = 0
|
|
}
|
|
|
|
// Same refcount pattern as `initFindInPageListener`, but for the
|
|
// "main-process Ctrl/Cmd+F forwarded to renderer" channel. On Pop!_OS /
|
|
// GNOME-based Linux distros the GTK compositor grabs Ctrl+F before the
|
|
// renderer's keydown listener can fire — the main process intercepts the
|
|
// chord via `before-input-event` and emits this IPC, so the renderer can
|
|
// still open the FindBar (#81727).
|
|
let openFindBarRefs = 0
|
|
let detachOpenFindBar: (() => void) | undefined
|
|
|
|
export function initOpenFindBarListener(): () => void {
|
|
openFindBarRefs += 1
|
|
|
|
if (openFindBarRefs === 1) {
|
|
detachOpenFindBar = window.hermesDesktop?.onOpenFindBarRequested?.(() => {
|
|
openFindBar()
|
|
})
|
|
}
|
|
|
|
let released = false
|
|
|
|
return () => {
|
|
if (released) {
|
|
return
|
|
}
|
|
|
|
released = true
|
|
openFindBarRefs -= 1
|
|
|
|
if (openFindBarRefs === 0) {
|
|
detachOpenFindBar?.()
|
|
detachOpenFindBar = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Test seam: number of live "open find bar" subscriptions. */
|
|
export function openFindBarListenerCount(): number {
|
|
return openFindBarRefs
|
|
}
|
|
|
|
/** Test seam: detach the open-find-bar bridge listener and zero the refcount. */
|
|
export function resetOpenFindBarListenerForTest(): void {
|
|
detachOpenFindBar?.()
|
|
detachOpenFindBar = undefined
|
|
openFindBarRefs = 0
|
|
}
|