import { useEffect, useMemo, useRef, useState } from "react"; import * as sync from "../syncEngine"; import * as searchIndex from "../search"; import type { NoteMeta, SearchHit, User } from "../types"; const DEBOUNCE_MS = 120; /** Refresh visible results every N notes while the backfill is still running. */ const PROGRESS_EVERY = 10; export interface SearchState { /** null means "not searching"; an empty array means "searched, found nothing". */ results: SearchHit[] | null; /** Excerpt around the match, empty when the note is not a hit. */ snippetFor: (id: string) => string; } /** * Full-text search over the local copy of every note. * * The server stores bodies encrypted and cannot index them, so the index is * built here. Bodies normally reach the client only when a note is opened, so * the missing ones are backfilled in the background after login. */ export function useSearchIndex(user: User | null, notes: NoteMeta[], query: string): SearchState { const [results, setResults] = useState(null); const [version, setVersion] = useState(0); const running = useRef(false); // Read by the backfill so it sees current titles without being torn down and // restarted every time a note changes. const notesRef = useRef(notes); notesRef.current = notes; useEffect(() => { // Waits for the note list: starting before the first sync would find // nothing to fetch and never run again. if (!user || notes.length === 0 || running.current) return; running.current = true; let cancelled = false; const signal = { aborted: false }; void (async () => { await searchIndex.rebuild(); if (!cancelled) setVersion((v) => v + 1); await sync.prefetchAllContent({ signal, onProgress: ({ id, content, fetched }) => { const note = notesRef.current.find((n) => n.id === id); searchIndex.setEntry(id, note?.title || "", content); if (fetched % PROGRESS_EVERY === 0 && !cancelled) setVersion((v) => v + 1); }, }); if (!cancelled) setVersion((v) => v + 1); running.current = false; })(); return () => { cancelled = true; signal.aborted = true; }; }, [user, notes.length]); useEffect(() => { const q = query.trim(); if (!q) { setResults(null); return; } // Debounced so typing does not re-scan the index on every keystroke. const t = setTimeout(() => setResults(searchIndex.search(q)), DEBOUNCE_MS); return () => clearTimeout(t); }, [query, version]); const snippets = useMemo(() => { const m = new Map(); for (const r of results || []) if (r.snippet) m.set(r.id, r.snippet); return m; }, [results]); const snippetFor = useMemo(() => (id: string) => snippets.get(id) || "", [snippets]); return { results, snippetFor }; }