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>
This commit is contained in:
436
design/search-subtitle-metadata.pen
Normal file
436
design/search-subtitle-metadata.pen
Normal file
@@ -0,0 +1,436 @@
|
||||
{"children":[
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f_search_meta",
|
||||
"name": "Search Result — with metadata subtitle",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 540,
|
||||
"height": "fit_content",
|
||||
"fill": "$--search-bg",
|
||||
"layout": "vertical",
|
||||
"padding": [24, 0],
|
||||
"gap": 0,
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_heading",
|
||||
"content": "Search Result Item — with metadata subtitle",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "700",
|
||||
"width": "fill_container",
|
||||
"padding": [0, 16, 12, 16]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_desc",
|
||||
"content": "Each search result now shows a metadata line below the snippet: modified date, word count, and link count. The type badge remains next to the title.",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"lineHeight": 1.5,
|
||||
"width": "fill_container",
|
||||
"padding": [0, 16, 16, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_countbar",
|
||||
"width": "fill_container",
|
||||
"padding": [6, 16],
|
||||
"stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_count",
|
||||
"content": "12 results · 148ms",
|
||||
"fill": "$--search-highlight",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_result1",
|
||||
"name": "Result 1 — Selected",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"fill": "$--accent",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_r1_titlerow",
|
||||
"width": "fill_container",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r1_title",
|
||||
"content": "How to Manage your Time",
|
||||
"fill": "$--search-heading",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_r1_badge",
|
||||
"cornerRadius": 3,
|
||||
"fill": "$--secondary",
|
||||
"padding": [2, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r1_badgeText",
|
||||
"content": "Essay",
|
||||
"fill": "$--search-text-muted",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r1_snippet",
|
||||
"content": "...how people can improve their time management skills. They gathered results in this article...",
|
||||
"fill": "$--search-snippet",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"lineHeight": 1.4,
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_r1_meta",
|
||||
"name": "metadata line",
|
||||
"width": "fill_container",
|
||||
"gap": 0,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r1_m1",
|
||||
"content": "Modified 2h ago · 1,240 words · 7 links",
|
||||
"fill": "$--search-text-dim",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_result2",
|
||||
"name": "Result 2 — Normal",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "top": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_r2_titlerow",
|
||||
"width": "fill_container",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r2_title",
|
||||
"content": "Weekly Review Process",
|
||||
"fill": "$--search-heading",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_r2_badge",
|
||||
"cornerRadius": 3,
|
||||
"fill": "$--secondary",
|
||||
"padding": [2, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r2_badgeText",
|
||||
"content": "Procedure",
|
||||
"fill": "$--search-text-muted",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r2_snippet",
|
||||
"content": "Every Sunday morning, review the week's progress and plan the next week...",
|
||||
"fill": "$--search-snippet",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"lineHeight": 1.4,
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_r2_meta",
|
||||
"name": "metadata line",
|
||||
"width": "fill_container",
|
||||
"gap": 0,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r2_m1",
|
||||
"content": "Modified 3d ago · 520 words · 12 links",
|
||||
"fill": "$--search-text-dim",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_result3",
|
||||
"name": "Result 3 — No links",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": { "align": "inside", "fill": "$--search-border", "thickness": { "top": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_r3_titlerow",
|
||||
"width": "fill_container",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r3_title",
|
||||
"content": "Morning Routine",
|
||||
"fill": "$--search-heading",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r3_snippet",
|
||||
"content": "A simple checklist for starting the day with intention...",
|
||||
"fill": "$--search-snippet",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"lineHeight": 1.4,
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f1_r3_meta",
|
||||
"name": "metadata line",
|
||||
"width": "fill_container",
|
||||
"gap": 0,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f1_r3_m1",
|
||||
"content": "Modified Jan 15 · 89 words",
|
||||
"fill": "$--search-text-dim",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f_notelist_meta",
|
||||
"name": "NoteList Item — subtitle with link count",
|
||||
"x": 600,
|
||||
"y": 0,
|
||||
"width": 340,
|
||||
"height": "fit_content",
|
||||
"fill": "$--card",
|
||||
"layout": "vertical",
|
||||
"padding": [24, 0],
|
||||
"gap": 0,
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f2_heading",
|
||||
"content": "NoteList Item — enhanced subtitle",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "700",
|
||||
"width": "fill_container",
|
||||
"padding": [0, 16, 12, 16]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f2_desc",
|
||||
"content": "Normal NoteList items add a link count after date and word count. The separator is a middle dot (·). When link count is 0, it's omitted.",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"lineHeight": 1.5,
|
||||
"width": "fill_container",
|
||||
"padding": [0, 16, 16, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f2_item1",
|
||||
"name": "NoteListItem — with links",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f2_i1_titlerow",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f2_i1_title",
|
||||
"content": "Build Laputa App",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f2_i1_sub",
|
||||
"content": "2h ago · 342 words · 5 links",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f2_item2",
|
||||
"name": "NoteListItem — no links",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f2_i2_titlerow",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f2_i2_title",
|
||||
"content": "Quick Meeting Notes",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f2_i2_sub",
|
||||
"content": "5d ago · 120 words",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f2_item3",
|
||||
"name": "NoteListItem — empty note",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"stroke": { "align": "inside", "fill": "$--border", "thickness": { "bottom": 1 } },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "f2_i3_titlerow",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f2_i3_title",
|
||||
"content": "New Untitled Note",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "f2_i3_sub",
|
||||
"content": "just now · Empty",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],"variables":{
|
||||
"--search-bg": {"type":"color","value":[{"value":"#FFFFFF"},{"theme":{"Mode":"Dark"},"value":"#1E1E2E"}]},
|
||||
"--search-heading": {"type":"color","value":[{"value":"#37352F"},{"theme":{"Mode":"Dark"},"value":"#e0e0f0"}]},
|
||||
"--search-snippet": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#888899"}]},
|
||||
"--search-text-dim": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#666680"}]},
|
||||
"--search-text-muted": {"type":"color","value":[{"value":"#787774"},{"theme":{"Mode":"Dark"},"value":"#8888aa"}]},
|
||||
"--search-highlight": {"type":"color","value":[{"value":"#37352F"},{"theme":{"Mode":"Dark"},"value":"#555570"}]},
|
||||
"--search-border": {"type":"color","value":[{"value":"#E9E9E7"},{"theme":{"Mode":"Dark"},"value":"#4c4c6d"}]},
|
||||
"--foreground": {"type":"color","value":[{"value":"#1C1C1C"},{"theme":{"Mode":"Dark"},"value":"#F5F5F5"}]},
|
||||
"--muted-foreground": {"type":"color","value":[{"value":"#737373"},{"theme":{"Mode":"Dark"},"value":"#A3A3A3"}]},
|
||||
"--border": {"type":"color","value":[{"value":"#E5E5E5"},{"theme":{"Mode":"Dark"},"value":"#333333"}]},
|
||||
"--card": {"type":"color","value":[{"value":"#FFFFFF"},{"theme":{"Mode":"Dark"},"value":"#1C1C1C"}]},
|
||||
"--accent": {"type":"color","value":[{"value":"#F5F5F5"},{"theme":{"Mode":"Dark"},"value":"#262626"}]},
|
||||
"--secondary": {"type":"color","value":[{"value":"#F5F5F5"},{"theme":{"Mode":"Dark"},"value":"#262626"}]}
|
||||
}}
|
||||
@@ -12,6 +12,8 @@ vi.mock('../mock-tauri', () => ({
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
|
||||
const NOW_TS = 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_TS - 3600,
|
||||
createdAt: NOW_TS - 86400 * 30,
|
||||
fileSize: 500,
|
||||
snippet: 'A guide to designing APIs for AI',
|
||||
wordCount: 0,
|
||||
wordCount: 1240,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['topic/ai', 'topic/apis', 'person/luca'],
|
||||
},
|
||||
{
|
||||
path: '/vault/event/retreat.md',
|
||||
@@ -52,8 +54,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000,
|
||||
createdAt: Date.now() / 1000,
|
||||
modifiedAt: NOW_TS,
|
||||
createdAt: NOW_TS,
|
||||
fileSize: 300,
|
||||
snippet: 'Team retreat event',
|
||||
wordCount: 0,
|
||||
@@ -371,6 +373,29 @@ describe('SearchPanel', () => {
|
||||
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows metadata subtitle on search results', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'API content', score: 0.9, note_type: 'Essay' },
|
||||
],
|
||||
elapsed_ms: 50,
|
||||
})
|
||||
|
||||
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(() => {
|
||||
const meta = screen.getByTestId('search-result-meta')
|
||||
expect(meta).toBeInTheDocument()
|
||||
expect(meta.textContent).toContain('words')
|
||||
expect(meta.textContent).toContain('3 links')
|
||||
expect(meta.textContent).toContain('Created')
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels inflight searches when panel closes', 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])
|
||||
|
||||
@@ -142,13 +143,92 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
},
|
||||
)
|
||||
|
||||
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, { isA: string | null }>
|
||||
entryLookup: Map<string, VaultEntry>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onSelect: (result: SearchResult) => void
|
||||
@@ -158,71 +238,50 @@ interface SearchContentProps {
|
||||
function SearchContent({
|
||||
query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover,
|
||||
}: SearchContentProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!query.trim() && (
|
||||
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>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/60">Enter to open · Esc to close</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{query.trim() && results.length === 0 && loading && (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
Searching...
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
{query.trim() && results.length === 0 && !loading && (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
<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) => {
|
||||
const isA = entryLookup.get(result.path)?.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
|
||||
return (
|
||||
<div
|
||||
key={result.path}
|
||||
className={cn(
|
||||
"cursor-pointer px-4 py-2.5 transition-colors",
|
||||
i === selectedIndex ? "bg-accent" : "hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => onSelect(result)}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/responsibility/grow-newsletter.md',
|
||||
@@ -69,7 +69,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/responsibility/manage-sponsorships.md',
|
||||
@@ -97,7 +97,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['person/matteo-cellini'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/procedure/write-weekly-essays.md',
|
||||
@@ -125,7 +125,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/procedure/run-sponsorships.md',
|
||||
@@ -153,7 +153,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['responsibility/manage-sponsorships'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/experiment/stock-screener.md',
|
||||
@@ -182,7 +182,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
|
||||
@@ -211,7 +211,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/budget-allocation.md',
|
||||
@@ -239,7 +239,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['project/26q1-laputa-app'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/matteo-cellini.md',
|
||||
@@ -375,7 +375,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/topic/software-development.md',
|
||||
@@ -459,7 +459,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay/engineering-leadership-101.md',
|
||||
@@ -488,7 +488,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay/ai-agents-primer.md',
|
||||
@@ -516,7 +516,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
// --- Type documents ---
|
||||
{
|
||||
@@ -1041,7 +1041,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
outgoingLinks: Array.from({ length: i % 8 }, (_, j) => `note/link-target-${(i + j) % 50}`),
|
||||
})
|
||||
}
|
||||
return entries
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { formatSubtitle, relativeDate } from './noteListHelpers'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate } from './noteListHelpers'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
@@ -48,6 +48,80 @@ describe('formatSubtitle', () => {
|
||||
expect(result).toContain('50 words')
|
||||
expect(result).toContain('\u00b7')
|
||||
})
|
||||
|
||||
it('includes link count when outgoingLinks is non-empty', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 200, outgoingLinks: ['a', 'b', 'c'] })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toContain('3 links')
|
||||
})
|
||||
|
||||
it('uses singular "link" when exactly one', () => {
|
||||
const entry = makeEntry({ wordCount: 100, outgoingLinks: ['one'] })
|
||||
expect(formatSubtitle(entry)).toContain('1 link')
|
||||
expect(formatSubtitle(entry)).not.toContain('1 links')
|
||||
})
|
||||
|
||||
it('omits link count when outgoingLinks is empty', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] })
|
||||
expect(formatSubtitle(entry)).not.toContain('link')
|
||||
})
|
||||
|
||||
it('formats word count with locale separators for large numbers', () => {
|
||||
const entry = makeEntry({ wordCount: 1240 })
|
||||
const result = formatSubtitle(entry)
|
||||
expect(result).toMatch(/1,?240 words/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSearchSubtitle', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('shows modified date, created date, word count, and links', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const entry = makeEntry({
|
||||
modifiedAt: now - 3600,
|
||||
createdAt: now - 86400 * 30,
|
||||
wordCount: 520,
|
||||
outgoingLinks: ['a', 'b', 'c', 'd', 'e'],
|
||||
})
|
||||
const result = formatSearchSubtitle(entry)
|
||||
expect(result).toContain('1h ago')
|
||||
expect(result).toContain('Created')
|
||||
expect(result).toContain('520 words')
|
||||
expect(result).toContain('5 links')
|
||||
})
|
||||
|
||||
it('omits created date when same as modified', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const entry = makeEntry({ modifiedAt: now, createdAt: now, wordCount: 100 })
|
||||
const result = formatSearchSubtitle(entry)
|
||||
expect(result).not.toContain('Created')
|
||||
})
|
||||
|
||||
it('omits created date when createdAt is null', () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const entry = makeEntry({ modifiedAt: now, createdAt: null, wordCount: 100 })
|
||||
const result = formatSearchSubtitle(entry)
|
||||
expect(result).not.toContain('Created')
|
||||
})
|
||||
|
||||
it('shows "Empty" for zero word count', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 })
|
||||
expect(formatSearchSubtitle(entry)).toContain('Empty')
|
||||
})
|
||||
|
||||
it('omits link count when no outgoing links', () => {
|
||||
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] })
|
||||
expect(formatSearchSubtitle(entry)).not.toContain('link')
|
||||
})
|
||||
|
||||
it('falls back to createdAt when modifiedAt is null', () => {
|
||||
const entry = makeEntry({ createdAt: 1700000000, wordCount: 200, outgoingLinks: ['a'] })
|
||||
const result = formatSearchSubtitle(entry)
|
||||
expect(result).toContain('200 words')
|
||||
expect(result).toContain('1 link')
|
||||
expect(result).not.toContain('Created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relativeDate', () => {
|
||||
|
||||
@@ -30,10 +30,31 @@ export function formatSubtitle(entry: VaultEntry): string {
|
||||
const date = getDisplayDate(entry)
|
||||
if (date) parts.push(relativeDate(date))
|
||||
if (entry.wordCount > 0) {
|
||||
parts.push(`${entry.wordCount} words`)
|
||||
parts.push(`${entry.wordCount.toLocaleString()} words`)
|
||||
} else {
|
||||
parts.push('Empty')
|
||||
}
|
||||
if (entry.outgoingLinks.length > 0) {
|
||||
parts.push(`${entry.outgoingLinks.length} ${entry.outgoingLinks.length === 1 ? 'link' : 'links'}`)
|
||||
}
|
||||
return parts.join(' \u00b7 ')
|
||||
}
|
||||
|
||||
export function formatSearchSubtitle(entry: VaultEntry): string {
|
||||
const parts: string[] = []
|
||||
const modified = entry.modifiedAt ?? entry.createdAt
|
||||
if (modified) parts.push(relativeDate(modified))
|
||||
if (entry.createdAt && entry.modifiedAt && entry.createdAt !== entry.modifiedAt) {
|
||||
parts.push(`Created ${relativeDate(entry.createdAt)}`)
|
||||
}
|
||||
if (entry.wordCount > 0) {
|
||||
parts.push(`${entry.wordCount.toLocaleString()} words`)
|
||||
} else {
|
||||
parts.push('Empty')
|
||||
}
|
||||
if (entry.outgoingLinks.length > 0) {
|
||||
parts.push(`${entry.outgoingLinks.length} ${entry.outgoingLinks.length === 1 ? 'link' : 'links'}`)
|
||||
}
|
||||
return parts.join(' \u00b7 ')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user