hololake-system-architecture/product-source/hololake-platform/src/hooks/useUnifiedSearch.ts

140 lines
4.2 KiB
TypeScript

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<string, unknown>): Promise<SearchResponseData> {
return isTauri()
? invoke<SearchResponseData>('search_vault', args)
: mockInvoke<SearchResponseData>('search_vault', args)
}
function mapResults(raw: SearchResultData[]): SearchResult[] {
const seen = new Set<string>()
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<SearchResult[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [loading, setLoading] = useState(false)
const [elapsedMs, setElapsedMs] = useState<number | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 }
}