feat(notelist): pin type document at top in section group view

When viewing a section group (e.g., Projects), the corresponding type
document (type/project.md) is now pinned at the top of the NoteList
as a prominent card. Clicking it navigates to the type document.
Instances of that type are listed below.

This makes the type document the entry point for each category,
reinforcing the "types are files" mental model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-20 21:47:42 +01:00
parent 2a564578a8
commit 05c840d6cf

View File

@@ -215,6 +215,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const isEntityView = selection.kind === 'entity'
const isSectionGroup = selection.kind === 'sectionGroup'
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => {
@@ -225,6 +226,13 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
})
}, [])
// Find the type document for this section group (e.g., type/project.md for "Project")
const typeDocument = useMemo(() => {
if (!isSectionGroup) return null
const typeName = (selection as { kind: 'sectionGroup'; type: string }).type
return entries.find((e) => e.isA === 'Type' && e.title === typeName) ?? null
}, [isSectionGroup, selection, entries])
const entityGroups = useMemo(
() => isEntityView ? buildRelationshipGroups(selection.entry, entries, allContent) : [],
[isEntityView, selection, entries, allContent]
@@ -308,12 +316,77 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
)
}, [selectedNote?.path, onSelectNote])
const renderPinnedView = useCallback((entity: VaultEntry, groups: RelationshipGroup[]) => {
const entityTypeColor = getTypeColor(entity.isA)
const entityLightColor = getTypeLightColor(entity.isA)
const EntityIcon = getTypeIcon(entity.isA)
return (
<div className="h-full overflow-y-auto">
{/* Prominent card */}
<div
className="relative cursor-pointer border-b border-[var(--border)]"
style={{ backgroundColor: entityLightColor, padding: '14px 16px' }}
onClick={() => onSelectNote(entity)}
>
<EntityIcon
width={16}
height={16}
className="absolute right-3 top-3.5"
style={{ color: entityTypeColor }}
data-testid="type-icon"
/>
<div className="pr-6 text-[14px] font-bold" style={{ color: entityTypeColor }}>
{entity.title}
</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: entityTypeColor, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entity.snippet}
</div>
<div className="mt-1 text-[11px] opacity-60" style={{ color: entityTypeColor }}>
{relativeDate(getDisplayDate(entity))}
</div>
</div>
{/* Relationship groups */}
{groups.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
{query ? 'No matching items' : 'No related items'}
</div>
) : (
groups.map((group) => {
const isGroupCollapsed = collapsedGroups.has(group.label)
return (
<div key={group.label}>
<button
className="flex w-full items-center justify-between border-none bg-muted cursor-pointer"
style={{ height: 32, padding: '0 16px' }}
onClick={() => toggleGroup(group.label)}
>
<span className="flex items-center gap-1.5">
<span className="font-mono-label text-muted-foreground">
{group.label}
</span>
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</span>
{isGroupCollapsed
? <CaretRight size={12} className="text-muted-foreground" />
: <CaretDown size={12} className="text-muted-foreground" />
}
</button>
{!isGroupCollapsed && group.entries.map((groupEntry) => renderItem(groupEntry))}
</div>
)
})
)}
</div>
)
}, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
{/* Header */}
<div className="flex h-[45px] shrink-0 items-center justify-between border-b border-border px-4" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">
{isEntityView ? selection.entry.title : 'Notes'}
{isEntityView ? selection.entry.title : (typeDocument ? typeDocument.title : 'Notes')}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<button
@@ -350,82 +423,39 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{isEntityView ? (() => {
const entity = selection.entry
const entityTypeColor = getTypeColor(entity.isA)
const entityLightColor = getTypeLightColor(entity.isA)
const EntityIcon = getTypeIcon(entity.isA)
return (
<div className="h-full overflow-y-auto">
{/* Prominent card */}
return renderPinnedView(entity, searchedGroups)
})() : (
<div className="h-full overflow-y-auto">
{/* Type document pinned card (for sectionGroup view) */}
{typeDocument && (
<div
className="relative cursor-pointer border-b border-[var(--border)]"
style={{ backgroundColor: entityLightColor, padding: '14px 16px' }}
onClick={() => onSelectNote(entity)}
style={{ backgroundColor: getTypeLightColor(typeDocument.isA), padding: '14px 16px' }}
onClick={() => onSelectNote(typeDocument)}
>
<EntityIcon
width={16}
height={16}
className="absolute right-3 top-3.5"
style={{ color: entityTypeColor }}
data-testid="type-icon"
/>
<div className="pr-6 text-[14px] font-bold" style={{ color: entityTypeColor }}>
{entity.title}
{(() => { const TDIcon = getTypeIcon(typeDocument.isA); return (
<TDIcon
width={16}
height={16}
className="absolute right-3 top-3.5"
style={{ color: getTypeColor(typeDocument.isA) }}
data-testid="type-icon"
/>
) })()}
<div className="pr-6 text-[14px] font-bold" style={{ color: getTypeColor(typeDocument.isA) }}>
{typeDocument.title}
</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: entityTypeColor, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entity.snippet}
</div>
<div className="mt-1 text-[11px] opacity-60" style={{ color: entityTypeColor }}>
{relativeDate(getDisplayDate(entity))}
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: getTypeColor(typeDocument.isA), display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{typeDocument.snippet}
</div>
</div>
{/* Relationship groups */}
{searchedGroups.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
{query ? 'No matching items' : 'No related items'}
</div>
) : (
searchedGroups.map((group) => {
const isCollapsed = collapsedGroups.has(group.label)
return (
<div key={group.label}>
<button
className="flex w-full items-center justify-between border-none bg-muted cursor-pointer"
style={{ height: 32, padding: '0 16px' }}
onClick={() => toggleGroup(group.label)}
>
<span className="flex items-center gap-1.5">
<span className="font-mono-label text-muted-foreground">
{group.label}
</span>
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</span>
{isCollapsed
? <CaretRight size={12} className="text-muted-foreground" />
: <CaretDown size={12} className="text-muted-foreground" />
}
</button>
{!isCollapsed && group.entries.map((entry) => renderItem(entry))}
</div>
)
})
)}
</div>
)
})() : (
searched.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
) : (
<Virtuoso
style={{ height: '100%' }}
totalCount={searched.length}
initialItemCount={Math.min(searched.length, 30)}
itemContent={(index) => {
const entry = searched[index]
return entry ? renderItem(entry) : null
}}
/>
)
)}
{searched.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
) : (
searched.map((entry) => renderItem(entry))
)}
</div>
)}
</div>
</div>