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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user