diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 92b463db..ab300efb 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -187,4 +187,38 @@ describe('NoteList', () => { expect(screen.getByTitle('Search notes')).toBeInTheDocument() expect(screen.getByTitle('Create new note')).toBeInTheDocument() }) + + it('context view shows backlinks from allContent', () => { + const allContent = { + [mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.', + } + render( + + ) + expect(screen.getByText('Backlinks')).toBeInTheDocument() + expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() + }) + + it('context view collapses and expands groups', () => { + render( + + ) + // Children group is expanded by default + expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + // Click the Children header to collapse + fireEvent.click(screen.getByText('Children')) + // Items should be hidden + expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument() + // Click again to expand + fireEvent.click(screen.getByText('Children')) + expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() + }) + + it('context view shows prominent card with entity snippet', () => { + render( + + ) + // Snippet appears in the prominent card + expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument() + }) }) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index b21ac167..afd0735e 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils' import { Input } from '@/components/ui/input' import { MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise, - Users, CalendarBlank, Tag, FileText, + Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, } from '@phosphor-icons/react' import type { ComponentType, SVGAttributes } from 'react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' @@ -86,7 +86,30 @@ function sortByModified(a: VaultEntry, b: VaultEntry): number { return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) } -function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]): RelationshipGroup[] { +function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record): VaultEntry[] { + const stem = entity.filename.replace(/\.md$/, '') + const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + const targets = [entity.title, ...entity.aliases] + + return allEntries.filter((e) => { + if (e.path === entity.path) return false + const content = allContent[e.path] + if (!content) return false + for (const t of targets) { + if (content.includes(`[[${t}]]`)) return true + } + if (content.includes(`[[${stem}]]`)) return true + if (content.includes(`[[${pathStem}]]`)) return true + if (content.includes(`[[${pathStem}|`)) return true + return false + }) +} + +function buildRelationshipGroups( + entity: VaultEntry, + allEntries: VaultEntry[], + allContent: Record, +): RelationshipGroup[] { const groups: RelationshipGroup[] = [] const seen = new Set([entity.path]) @@ -131,6 +154,13 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]): relatedTo.forEach((e) => seen.add(e.path)) } + const backlinks = findBacklinks(entity, allEntries, allContent) + .filter((e) => !seen.has(e.path)) + .sort(sortByModified) + if (backlinks.length > 0) { + groups.push({ label: 'Backlinks', entries: backlinks }) + } + return groups } @@ -155,15 +185,25 @@ function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modi } } -function NoteListInner({ entries, selection, selectedNote, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const isEntityView = selection.kind === 'entity' + const toggleGroup = useCallback((label: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev) + if (next.has(label)) next.delete(label) + else next.add(label) + return next + }) + }, []) + const entityGroups = useMemo( - () => isEntityView ? buildRelationshipGroups(selection.entry, entries) : [], - [isEntityView, selection, entries] + () => isEntityView ? buildRelationshipGroups(selection.entry, entries, allContent) : [], + [isEntityView, selection, entries, allContent] ) const filtered = useMemo( @@ -284,28 +324,70 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, onSele {/* Items */}
- {isEntityView ? ( -
- {renderItem(selection.entry, true)} - {searchedGroups.length === 0 ? ( -
- {query ? 'No matching items' : 'No related items'} -
- ) : ( - searchedGroups.map((group) => ( -
-
- - {group.label} - - {group.entries.length} -
- {group.entries.map((entry) => renderItem(entry))} + {isEntityView ? (() => { + const entity = selection.entry + const entityTypeColor = getTypeColor(entity.isA) + const entityLightColor = getTypeLightColor(entity.isA) + const EntityIcon = getTypeIcon(entity.isA) + return ( +
+ {/* Prominent card */} +
onSelectNote(entity)} + > + +
+ {entity.title}
- )) - )} -
- ) : ( +
+ {entity.snippet} +
+
+ {relativeDate(getDisplayDate(entity))} +
+
+ + {/* Relationship groups */} + {searchedGroups.length === 0 ? ( +
+ {query ? 'No matching items' : 'No related items'} +
+ ) : ( + searchedGroups.map((group) => { + const isCollapsed = collapsedGroups.has(group.label) + return ( +
+ + {!isCollapsed && group.entries.map((entry) => renderItem(entry))} +
+ ) + }) + )} +
+ ) + })() : ( searched.length === 0 ? (
No notes found
) : (