* design: search subtitle metadata layout with 2 frames Frame 1: Search result items with metadata subtitle line showing modified date, word count, and link count below the snippet. Frame 2: NoteList items with enhanced subtitle including link count. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add metadata subtitle to search results and note list items - formatSubtitle now shows link count (e.g. "2h ago · 342 words · 5 links") - New formatSearchSubtitle shows full metadata in search results: modified date, created date, word count, and link count - SearchPanel renders metadata line under each search result snippet - Mock entries updated with realistic outgoingLinks data - 11 new tests covering formatSearchSubtitle and search result metadata Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract SearchResultItem and SearchResultsList from SearchContent Reduces cyclomatic complexity from 24 to manageable levels by splitting the monolithic SearchContent into three focused components. Code Health: 8.97 -> 9.23. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
288 lines
9.1 KiB
TypeScript
288 lines
9.1 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>
|
|
)
|
|
},
|
|
)
|
|
|
|
function SearchResultItem({ result, isSelected, entry, typeEntryMap, onSelect, onHover }: {
|
|
result: SearchResult
|
|
isSelected: boolean
|
|
entry: VaultEntry | undefined
|
|
typeEntryMap: Record<string, VaultEntry>
|
|
onSelect: () => void
|
|
onHover: () => void
|
|
}) {
|
|
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
|
|
className={cn(
|
|
"cursor-pointer px-4 py-2.5 transition-colors",
|
|
isSelected ? "bg-accent" : "hover:bg-secondary",
|
|
)}
|
|
onClick={onSelect}
|
|
onMouseEnter={onHover}
|
|
>
|
|
<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>
|
|
{result.snippet && (
|
|
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
|
|
{result.snippet}
|
|
</p>
|
|
)}
|
|
{subtitle && (
|
|
<p className="mt-1 text-[11px] text-muted-foreground/70" data-testid="search-result-meta">
|
|
{subtitle}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SearchResultsList({ results, selectedIndex, entryLookup, typeEntryMap, elapsedMs, listRef, onSelect, onHover }: {
|
|
results: SearchResult[]
|
|
selectedIndex: number
|
|
entryLookup: Map<string, VaultEntry>
|
|
typeEntryMap: Record<string, VaultEntry>
|
|
elapsedMs: number | null
|
|
listRef: React.RefObject<HTMLDivElement | null>
|
|
onSelect: (result: SearchResult) => void
|
|
onHover: (index: number) => void
|
|
}) {
|
|
return (
|
|
<>
|
|
<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) => (
|
|
<SearchResultItem
|
|
key={result.path}
|
|
result={result}
|
|
isSelected={i === selectedIndex}
|
|
entry={entryLookup.get(result.path)}
|
|
typeEntryMap={typeEntryMap}
|
|
onSelect={() => onSelect(result)}
|
|
onHover={() => onHover(i)}
|
|
/>
|
|
))}
|
|
</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) {
|
|
const hasQuery = query.trim().length > 0
|
|
const hasResults = results.length > 0
|
|
|
|
if (!hasQuery) {
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!hasResults && loading) {
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">Searching...</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!hasResults) {
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="px-4 py-8 text-center">
|
|
<p className="text-[13px] text-muted-foreground">No results found</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<SearchResultsList
|
|
results={results}
|
|
selectedIndex={selectedIndex}
|
|
entryLookup={entryLookup}
|
|
typeEntryMap={typeEntryMap}
|
|
elapsedMs={elapsedMs}
|
|
listRef={listRef}
|
|
onSelect={onSelect}
|
|
onHover={onHover}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|