Files
tolaria/src/components/SearchPanel.tsx
Luca Rossi c44f5887ae refactor: extract SearchResultItem and SearchResultsList from SearchContent (#124)
* 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>
2026-02-27 15:27:11 +01:00

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>
)
}