import { useState, useRef, useEffect, useCallback } from 'react' import type { SearchResult } from '../types' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import { GITIGNORED_VISIBILITY_CHANGED_EVENT } from '../lib/gitignoredVisibilityEvents' interface SearchResultData { title: string path: string snippet: string score: number note_type: string | null } interface SearchResponseData { results: SearchResultData[] elapsed_ms: number } const DEBOUNCE_MS = 300 function searchCall(args: Record): Promise { return isTauri() ? invoke('search_vault', args) : mockInvoke('search_vault', args) } function mapResults(raw: SearchResultData[]): SearchResult[] { const seen = new Set() return raw .map(r => ({ title: r.title, path: r.path, snippet: r.snippet, score: r.score, noteType: r.note_type, })) .filter(r => { if (seen.has(r.path)) return false if (r.noteType === 'Config') return false seen.add(r.path) return true }) } function useGitignoredVisibilitySearchRefresh({ active, performSearch, query, }: { active: boolean performSearch: (query: string) => void query: string }) { useEffect(() => { if (typeof window === 'undefined') return const handleGitignoredVisibilityChanged = () => { if (active && query.trim()) performSearch(query) } window.addEventListener(GITIGNORED_VISIBILITY_CHANGED_EVENT, handleGitignoredVisibilityChanged) return () => { window.removeEventListener(GITIGNORED_VISIBILITY_CHANGED_EVENT, handleGitignoredVisibilityChanged) } }, [active, performSearch, query]) } export function useUnifiedSearch(vaultPath: string | string[], active: boolean) { const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [selectedIndex, setSelectedIndex] = useState(0) const [loading, setLoading] = useState(false) const [elapsedMs, setElapsedMs] = useState(null) const debounceRef = useRef | null>(null) const searchGenRef = useRef(0) const reset = useCallback(() => { setQuery('') setResults([]) setSelectedIndex(0) setElapsedMs(null) setLoading(false) searchGenRef.current++ }, []) // On any active change: cancel inflight + debounce, then reset if opening useEffect(() => { searchGenRef.current++ clearTimeout(debounceRef.current ?? undefined) debounceRef.current = null if (active) reset() }, [active, reset]) const performSearch = useCallback(async (q: string) => { if (!q.trim()) { setResults([]); setElapsedMs(null); setLoading(false); return } searchGenRef.current++ const gen = searchGenRef.current setLoading(true) try { const paths = Array.isArray(vaultPath) ? vaultPath : [vaultPath] const responses = await Promise.all(paths .filter((path) => path.trim().length > 0) .map((path) => searchCall({ vaultPath: path, query: q, mode: 'keyword', limit: 20 }))) if (gen !== searchGenRef.current) return setResults(mapResults(responses.flatMap((response) => response.results)).slice(0, 20)) setElapsedMs(responses.reduce((sum, response) => sum + response.elapsed_ms, 0)) setSelectedIndex(0) } catch { if (gen !== searchGenRef.current) return } finally { if (gen === searchGenRef.current) setLoading(false) } }, [vaultPath]) useEffect(() => { clearTimeout(debounceRef.current ?? undefined) debounceRef.current = null if (!query.trim()) { setResults([]) setElapsedMs(null) searchGenRef.current++ setLoading(false) return } debounceRef.current = setTimeout(() => performSearch(query), DEBOUNCE_MS) return () => { clearTimeout(debounceRef.current ?? undefined) debounceRef.current = null } }, [query, performSearch]) useGitignoredVisibilitySearchRefresh({ active, performSearch: (nextQuery) => { void performSearch(nextQuery) }, query, }) return { query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs } }