From f3dcaadca83fe158857b2c8a4099915f395ce0d8 Mon Sep 17 00:00:00 2001 From: Laputa App Date: Fri, 27 Feb 2026 18:06:49 +0100 Subject: [PATCH] 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 --- src/components/SearchPanel.test.tsx | 61 +++++++++++++++++++++++++---- src/components/SearchPanel.tsx | 17 ++++---- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx index 94484e0c..51c4fbce 100644 --- a/src/components/SearchPanel.test.tsx +++ b/src/components/SearchPanel.test.tsx @@ -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( + , + ) + + 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( + , + ) + + 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( diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index 28e05b6d..10c9e69e 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -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() - for (const e of entries) map.set(e.path, { isA: e.isA }) + const map = new Map() + 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 + entryLookup: Map typeEntryMap: Record listRef: React.RefObject onSelect: (result: SearchResult) => void @@ -190,10 +191,12 @@ function SearchContent({
{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 (
)}
- {result.snippet && ( -

- {result.snippet} + {subtitle && ( +

+ {subtitle}

)}