feat: replace search result snippet with metadata subtitle
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>
This commit is contained in:
@@ -12,6 +12,8 @@ vi.mock('../mock-tauri', () => ({
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000)
|
||||
|
||||
const MOCK_ENTRIES: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/essay/ai-apis.md',
|
||||
@@ -27,16 +29,16 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000,
|
||||
createdAt: Date.now() / 1000,
|
||||
modifiedAt: NOW - 7200,
|
||||
createdAt: NOW - 86400 * 30,
|
||||
fileSize: 500,
|
||||
snippet: 'A guide to designing APIs for AI',
|
||||
wordCount: 0,
|
||||
wordCount: 1247,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
|
||||
},
|
||||
{
|
||||
path: '/vault/event/retreat.md',
|
||||
@@ -52,16 +54,16 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000,
|
||||
createdAt: Date.now() / 1000,
|
||||
modifiedAt: NOW - 86400 * 5,
|
||||
createdAt: NOW - 86400 * 5,
|
||||
fileSize: 300,
|
||||
snippet: 'Team retreat event',
|
||||
wordCount: 0,
|
||||
wordCount: 856,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['person/bob'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -270,6 +272,49 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows metadata subtitle with word count and links', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'Content', score: 0.9, note_type: null },
|
||||
],
|
||||
elapsed_ms: 20,
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1,247 words/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/3 links/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('omits links from subtitle when entry has zero outgoing links', async () => {
|
||||
const noLinksEntries = MOCK_ENTRIES.map(e =>
|
||||
e.path === '/vault/essay/ai-apis.md' ? { ...e, outgoingLinks: [] } : e,
|
||||
)
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null },
|
||||
],
|
||||
elapsed_ms: 20,
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={noLinksEntries} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1,247 words/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/links/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading spinner while searching', async () => {
|
||||
const resolvers: ((v: unknown) => void)[] = []
|
||||
mockInvokeFn.mockImplementation(
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -63,8 +64,8 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const entryLookup = useMemo(() => {
|
||||
const map = new Map<string, { isA: string | null }>()
|
||||
for (const e of entries) map.set(e.path, { isA: e.isA })
|
||||
const map = new Map<string, VaultEntry>()
|
||||
for (const e of entries) map.set(e.path, e)
|
||||
return map
|
||||
}, [entries])
|
||||
|
||||
@@ -148,7 +149,7 @@ interface SearchContentProps {
|
||||
selectedIndex: number
|
||||
loading: boolean
|
||||
elapsedMs: number | null
|
||||
entryLookup: Map<string, { isA: string | null }>
|
||||
entryLookup: Map<string, VaultEntry>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onSelect: (result: SearchResult) => void
|
||||
@@ -190,10 +191,12 @@ function SearchContent({
|
||||
</div>
|
||||
<div ref={listRef}>
|
||||
{results.map((result, i) => {
|
||||
const isA = entryLookup.get(result.path)?.isA ?? result.noteType
|
||||
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}
|
||||
@@ -212,9 +215,9 @@ function SearchContent({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{result.snippet}
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user