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:
Laputa App
2026-02-27 18:06:49 +01:00
parent 6e359d5b9b
commit f3dcaadca8
2 changed files with 63 additions and 15 deletions

View File

@@ -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(

View File

@@ -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>