import { createElement, forwardRef, useRef, useEffect, useCallback, useLayoutEffect, useMemo } from 'react' import { cn } from '@/lib/utils' import type { SearchResult, VaultEntry } from '../types' import { useUnifiedSearch } from '../hooks/useUnifiedSearch' import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors' import { formatSearchSubtitle } from '../utils/noteListHelpers' import type { DateDisplayFormat } from '../utils/dateDisplay' import { scrollSelectedHTMLChildIntoView } from '../utils/domScroll' import { getTypeIcon } from './NoteItem' import { NoteTitleIcon } from './NoteTitleIcon' import { WorkspaceInitialsBadge } from './WorkspaceInitialsBadge' import { useDateDisplayFormat } from '../hooks/useAppPreferences' interface SearchPanelProps { open: boolean vaultPath: string entries: VaultEntry[] onSelectNote: (entry: VaultEntry) => void onClose: () => void } type SearchKeyboardAction = 'close' | 'next' | 'previous' | 'select' // WKWebView can emit duplicate non-text navigation keydowns around native key injection. const NATIVE_KEYDOWN_DUPLICATE_WINDOW_MS = 500 const handledSearchKeyboardEvents = new WeakSet() interface SearchKeyboardEvent { key: string nativeEvent?: Event preventDefault: () => void repeat?: boolean stopImmediatePropagation?: () => void stopPropagation?: () => void timeStamp?: number } interface SearchKeydownRecord { key: string timeStamp: number } interface SearchKeyboardActionContext { handleSelect: (result: SearchResult) => void onClose: () => void resultsRef: React.MutableRefObject selectedIndexRef: React.MutableRefObject setSelectedIndex: React.Dispatch> } function resolveSearchKeyboardAction(key: string): SearchKeyboardAction | null { switch (key) { case 'Escape': return 'close' case 'ArrowDown': return 'next' case 'ArrowUp': return 'previous' case 'Enter': return 'select' default: return null } } function nextSearchSelectionIndex( action: Extract, currentIndex: number, resultCount: number, ): number { if (resultCount <= 0) return 0 if (action === 'next') return Math.min(currentIndex + 1, resultCount - 1) return Math.max(currentIndex - 1, 0) } function shouldHandleKeydown( event: SearchKeyboardEvent, pressedKeys: Set, handledEvents: WeakSet, recentKeydownRef: React.MutableRefObject, ): boolean { const eventIdentity = resolveSearchKeyboardEventIdentity(event) if (eventIdentity) { if (handledEvents.has(eventIdentity)) return false handledEvents.add(eventIdentity) } if (isDuplicateNativeKeydown(event, recentKeydownRef.current)) { return false } rememberSearchKeydown(event, recentKeydownRef) if (event.repeat) return true if (pressedKeys.has(event.key)) return false pressedKeys.add(event.key) return true } function isDuplicateNativeKeydown( event: SearchKeyboardEvent, previous: SearchKeydownRecord | null, ): boolean { const timeStamp = resolveSearchKeyboardEventTimestamp(event) if (!previous || timeStamp === null || previous.key !== event.key) return false const elapsedMs = timeStamp - previous.timeStamp return elapsedMs >= 0 && elapsedMs <= NATIVE_KEYDOWN_DUPLICATE_WINDOW_MS } function rememberSearchKeydown( event: SearchKeyboardEvent, recentKeydownRef: React.MutableRefObject, ) { const timeStamp = resolveSearchKeyboardEventTimestamp(event) if (timeStamp !== null) recentKeydownRef.current = { key: event.key, timeStamp } } function resolveSearchKeyboardEventTimestamp(event: SearchKeyboardEvent): number | null { if (typeof performance !== 'undefined' && typeof performance.now === 'function') return performance.now() const { timeStamp } = event return typeof timeStamp === 'number' && Number.isFinite(timeStamp) ? timeStamp : null } function resolveSearchKeyboardEventIdentity(event: SearchKeyboardEvent): Event | null { if (event.nativeEvent instanceof Event) return event.nativeEvent if (event instanceof Event) return event return null } function applySearchSelection( action: Extract, resultsRef: React.MutableRefObject, selectedIndexRef: React.MutableRefObject, setSelectedIndex: React.Dispatch>, ) { const nextIndex = nextSearchSelectionIndex(action, selectedIndexRef.current, resultsRef.current.length) selectedIndexRef.current = nextIndex setSelectedIndex(nextIndex) } function performSearchKeyboardAction(action: SearchKeyboardAction, context: SearchKeyboardActionContext) { if (action === 'close') { context.onClose() return } if (action === 'select') { const result = context.resultsRef.current[context.selectedIndexRef.current] if (result) context.handleSelect(result) return } applySearchSelection(action, context.resultsRef, context.selectedIndexRef, context.setSelectedIndex) } function useSearchKeyboardDocumentListeners({ handleKeyDown, handleKeyUp, open, pressedKeysRef, }: { handleKeyDown: (event: KeyboardEvent) => void handleKeyUp: (event: KeyboardEvent) => void open: boolean pressedKeysRef: React.MutableRefObject> }) { useEffect(() => { const pressedKeys = pressedKeysRef.current if (!open) { pressedKeys.clear() return } document.addEventListener('keydown', handleKeyDown, true) document.addEventListener('keyup', handleKeyUp, true) return () => { document.removeEventListener('keydown', handleKeyDown, true) document.removeEventListener('keyup', handleKeyUp, true) pressedKeys.clear() } }, [handleKeyDown, handleKeyUp, open, pressedKeysRef]) } function searchVaultPathsForEntries(entries: VaultEntry[], fallbackVaultPath: string): string | string[] { const paths = entries .map((entry) => entry.workspace?.path) .filter((path): path is string => !!path) return paths.length > 0 ? [...new Set(paths)] : fallbackVaultPath } function shouldShowWorkspace(entries: VaultEntry[]): boolean { return new Set(entries.map((entry) => entry.workspace?.alias).filter(Boolean)).size > 1 } function useSearchSelectionRefs(results: SearchResult[], selectedIndex: number) { const resultsRef = useRef(results) const selectedIndexRef = useRef(selectedIndex) useLayoutEffect(() => { resultsRef.current = results selectedIndexRef.current = selectedIndex }, [results, selectedIndex]) return { resultsRef, selectedIndexRef } } function useSearchEntryData(entries: VaultEntry[]) { const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const entryLookup = useMemo(() => { const map = new Map() for (const e of entries) map.set(e.path, e) return map }, [entries]) const showWorkspace = useMemo(() => shouldShowWorkspace(entries), [entries]) return { entryLookup, showWorkspace, typeEntryMap } } function useSearchKeyboard({ open, onClose, handleSelect, resultsRef, selectedIndexRef, setSelectedIndex, }: { open: boolean onClose: () => void handleSelect: (result: SearchResult) => void resultsRef: React.MutableRefObject selectedIndexRef: React.MutableRefObject setSelectedIndex: React.Dispatch> }) { const pressedKeysRef = useRef(new Set()) const recentKeydownRef = useRef(null) const handleKeyDown = useCallback((e: SearchKeyboardEvent) => { const action = resolveSearchKeyboardAction(e.key) if (!action) return e.preventDefault() e.stopImmediatePropagation?.() e.stopPropagation?.() if (!shouldHandleKeydown(e, pressedKeysRef.current, handledSearchKeyboardEvents, recentKeydownRef)) return performSearchKeyboardAction(action, { handleSelect, onClose, resultsRef, selectedIndexRef, setSelectedIndex }) }, [handleSelect, onClose, resultsRef, selectedIndexRef, setSelectedIndex]) const handleKeyUp = useCallback((e: { key: string }) => { if (resolveSearchKeyboardAction(e.key)) pressedKeysRef.current.delete(e.key) }, []) useSearchKeyboardDocumentListeners({ handleKeyDown, handleKeyUp, open, pressedKeysRef }) } function useSearchPanelController({ open, vaultPath, entries, onSelectNote, onClose }: SearchPanelProps) { const searchVaultPaths = useMemo(() => searchVaultPathsForEntries(entries, vaultPath), [entries, vaultPath]) const { query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs, } = useUnifiedSearch(searchVaultPaths, open) const inputRef = useRef(null) const listRef = useRef(null) const { resultsRef, selectedIndexRef } = useSearchSelectionRefs(results, selectedIndex) useEffect(() => { scrollSelectedHTMLChildIntoView(listRef.current, selectedIndex) }, [selectedIndex]) const handleSelect = useCallback((result: SearchResult) => { const entry = entries.find(e => e.path === result.path) if (entry) { onSelectNote(entry) onClose() } }, [entries, onSelectNote, onClose]) useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 50) }, [open]) useSearchKeyboard({ open, onClose, handleSelect, resultsRef, selectedIndexRef, setSelectedIndex, }) const entryData = useSearchEntryData(entries) return { elapsedMs, handleSelect, inputRef, listRef, loading, query, results, selectedIndex, setQuery, setSelectedIndex, ...entryData, } } export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose, }: SearchPanelProps) { const dateDisplayFormat = useDateDisplayFormat() const rootRef = useRef(null) const { elapsedMs, entryLookup, handleSelect, inputRef, listRef, loading, query, results, selectedIndex, setQuery, setSelectedIndex, showWorkspace, typeEntryMap, } = useSearchPanelController({ open, vaultPath, entries, onSelectNote, onClose }) const handleResultHover = useCallback((index: number, event: React.MouseEvent) => { if (shouldApplySearchResultHover(event)) setSelectedIndex(index) }, [setSelectedIndex]) useEffect(() => { if (!open) return const root = rootRef.current if (!root) return const handleRootClick = (event: MouseEvent) => { if (event.target === root) onClose() } root.addEventListener('click', handleRootClick) return () => root.removeEventListener('click', handleRootClick) }, [open, onClose]) if (!open) return null return (
) } interface SearchInputProps { query: string loading: boolean onChange: (value: string) => void } const SearchInput = forwardRef( function SearchInput({ query, loading, onChange }, ref) { return (
onChange(e.target.value)} /> {loading && ( )}
) }, ) interface SearchContentProps { query: string results: SearchResult[] selectedIndex: number loading: boolean elapsedMs: number | null entryLookup: Map typeEntryMap: Record showWorkspace: boolean dateDisplayFormat: DateDisplayFormat listRef: React.RefObject onSelect: (result: SearchResult) => void onHover: (index: number, event: React.MouseEvent) => void } interface SearchResultRowProps { result: SearchResult entry: VaultEntry | undefined selected: boolean index: number typeEntryMap: Record showWorkspace: boolean dateDisplayFormat: DateDisplayFormat onSelect: (result: SearchResult) => void onHover: (index: number, event: React.MouseEvent) => void } interface SearchResultPresentation { TypeIcon: ReturnType icon?: string | null noteType: string | null subtitle: string | null title: string typeColor?: string workspace: VaultEntry['workspace'] | null } function resolveSearchResultPresentation({ result, entry, typeEntryMap, showWorkspace, dateDisplayFormat, }: Pick): SearchResultPresentation { const isA = entry?.isA ?? result.noteType const noteType = isA || null const typeEntry = typeEntryMap[isA ?? ''] return { TypeIcon: getTypeIcon(isA ?? null, typeEntry?.icon), icon: entry?.icon, noteType, subtitle: entry ? formatSearchSubtitle(entry, dateDisplayFormat) : null, title: entry?.title ?? result.title, typeColor: resolveSearchResultTypeColor(noteType, isA, typeEntry), workspace: resolveSearchResultWorkspace(showWorkspace, entry), } } function resolveSearchResultTypeColor( noteType: string | null, isA: string | null, typeEntry: VaultEntry | undefined, ): string | undefined { return noteType ? getTypeColor(isA, typeEntry?.color) : undefined } function resolveSearchResultWorkspace(showWorkspace: boolean, entry: VaultEntry | undefined): VaultEntry['workspace'] | null { return showWorkspace ? entry?.workspace ?? null : null } function SearchResultRow({ result, entry, selected, index, typeEntryMap, showWorkspace, dateDisplayFormat, onSelect, onHover, }: SearchResultRowProps) { const presentation = resolveSearchResultPresentation({ result, entry, typeEntryMap, showWorkspace, dateDisplayFormat, }) return (
onSelect(result)} onMouseMove={(event) => onHover(index, event)} >
{createElement(presentation.TypeIcon, { width: 14, height: 14, className: 'shrink-0', style: { color: presentation.typeColor ?? 'var(--muted-foreground)' }, })}
) } function SearchResultTitle({ icon, title }: { icon?: string | null; title: string }) { return ( {title} ) } function SearchResultTypeLabel({ noteType }: { noteType: string | null }) { return noteType ? {noteType} : null } function SearchResultSubtitle({ subtitle }: { subtitle: string | null }) { return subtitle ?

{subtitle}

: null } function SearchIdleMessage() { return (

Search across all note contents

Enter to open · Esc to close

) } function SearchLoadingMessage() { return
Searching...
} function SearchNoResultsMessage() { return (

No results found

) } function SearchResultsHeader({ count, elapsedMs }: { count: number; elapsedMs: number | null }) { return (
{count} result{count !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
) } function SearchContent({ query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, showWorkspace, dateDisplayFormat, listRef, onSelect, onHover, }: SearchContentProps) { const hasQuery = query.trim().length > 0 const hasResults = results.length > 0 return (
{!hasQuery && } {hasQuery && !hasResults && loading && } {hasQuery && !hasResults && !loading && } {hasResults && ( <>
{results.map((result, i) => ( ))}
)}
) } function shouldApplySearchResultHover(event: React.MouseEvent): boolean { return event.movementX !== 0 || event.movementY !== 0 }