Replace the first-line-of-content snippet in search results with a metadata row showing: relative date, created date (when different from modified), word count, and outgoing link count. Uses the existing formatSearchSubtitle() helper. Entry lookup now stores full VaultEntry for metadata access. Graceful degradation when fields are missing/zero. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
232 lines
8.0 KiB
TypeScript
232 lines
8.0 KiB
TypeScript
import { useRef, useEffect, useCallback } from 'react'
|
|
import { useMemo } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import type { SearchResult, VaultEntry } from '../types'
|
|
import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
|
|
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
|
import { formatSearchSubtitle } from '../utils/noteListHelpers'
|
|
|
|
interface SearchPanelProps {
|
|
open: boolean
|
|
vaultPath: string
|
|
entries: VaultEntry[]
|
|
onSelectNote: (entry: VaultEntry) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: SearchPanelProps) {
|
|
const {
|
|
query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs,
|
|
} = useUnifiedSearch(vaultPath, open)
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (open) setTimeout(() => inputRef.current?.focus(), 50)
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!listRef.current) return
|
|
const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined
|
|
selected?.scrollIntoView({ block: 'nearest' })
|
|
}, [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) return
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
onClose()
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setSelectedIndex(i => Math.min(i + 1, results.length - 1))
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setSelectedIndex(i => Math.max(i - 1, 0))
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (results[selectedIndex]) handleSelect(results[selectedIndex])
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handleKey)
|
|
return () => window.removeEventListener('keydown', handleKey)
|
|
}, [open, results, selectedIndex, handleSelect, setSelectedIndex, onClose])
|
|
|
|
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
|
const entryLookup = useMemo(() => {
|
|
const map = new Map<string, VaultEntry>()
|
|
for (const e of entries) map.set(e.path, e)
|
|
return map
|
|
}, [entries])
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="flex w-[540px] max-w-[90vw] max-h-[480px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<SearchInput
|
|
ref={inputRef}
|
|
query={query}
|
|
loading={loading}
|
|
onChange={setQuery}
|
|
/>
|
|
<SearchContent
|
|
query={query}
|
|
results={results}
|
|
selectedIndex={selectedIndex}
|
|
loading={loading}
|
|
elapsedMs={elapsedMs}
|
|
entryLookup={entryLookup}
|
|
typeEntryMap={typeEntryMap}
|
|
listRef={listRef}
|
|
onSelect={handleSelect}
|
|
onHover={setSelectedIndex}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
import { forwardRef } from 'react'
|
|
|
|
interface SearchInputProps {
|
|
query: string
|
|
loading: boolean
|
|
onChange: (value: string) => void
|
|
}
|
|
|
|
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
|
function SearchInput({ query, loading, onChange }, ref) {
|
|
return (
|
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
|
<svg className="h-4 w-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="m21 21-4.35-4.35" />
|
|
</svg>
|
|
<input
|
|
ref={ref}
|
|
className="flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
|
type="text"
|
|
placeholder="Search in all notes..."
|
|
value={query}
|
|
onChange={e => onChange(e.target.value)}
|
|
/>
|
|
{loading && (
|
|
<svg
|
|
className="h-4 w-4 shrink-0 animate-spin text-muted-foreground"
|
|
data-testid="search-spinner"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
>
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
|
|
interface SearchContentProps {
|
|
query: string
|
|
results: SearchResult[]
|
|
selectedIndex: number
|
|
loading: boolean
|
|
elapsedMs: number | null
|
|
entryLookup: Map<string, VaultEntry>
|
|
typeEntryMap: Record<string, VaultEntry>
|
|
listRef: React.RefObject<HTMLDivElement | null>
|
|
onSelect: (result: SearchResult) => void
|
|
onHover: (index: number) => void
|
|
}
|
|
|
|
function SearchContent({
|
|
query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover,
|
|
}: SearchContentProps) {
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
{!query.trim() && (
|
|
<div className="px-4 py-8 text-center">
|
|
<p className="text-[13px] text-muted-foreground">Search across all note contents</p>
|
|
<p className="mt-1 text-[11px] text-muted-foreground/60">
|
|
Enter to open · Esc to close
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{query.trim() && results.length === 0 && loading && (
|
|
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
|
Searching...
|
|
</div>
|
|
)}
|
|
|
|
{query.trim() && results.length === 0 && !loading && (
|
|
<div className="px-4 py-8 text-center">
|
|
<p className="text-[13px] text-muted-foreground">No results found</p>
|
|
</div>
|
|
)}
|
|
|
|
{results.length > 0 && (
|
|
<>
|
|
<div className="border-b border-border/50 px-4 py-1.5">
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
|
|
</span>
|
|
</div>
|
|
<div ref={listRef}>
|
|
{results.map((result, i) => {
|
|
const entry = entryLookup.get(result.path)
|
|
const isA = entry?.isA ?? result.noteType
|
|
const noteType = isA && isA !== 'Note' ? isA : null
|
|
const typeColor = noteType ? getTypeColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
|
|
const typeLightColor = noteType ? getTypeLightColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
|
|
const subtitle = entry ? formatSearchSubtitle(entry) : null
|
|
return (
|
|
<div
|
|
key={result.path}
|
|
className={cn(
|
|
"cursor-pointer px-4 py-2.5 transition-colors",
|
|
i === selectedIndex ? "bg-accent" : "hover:bg-secondary",
|
|
)}
|
|
onClick={() => onSelect(result)}
|
|
onMouseEnter={() => onHover(i)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
|
|
{noteType && (
|
|
<Badge variant="secondary" className="text-[10px]" style={typeColor ? { color: typeColor, backgroundColor: typeLightColor } : undefined}>
|
|
{noteType}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{subtitle && (
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
{subtitle}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|