feat: add note count chips to sidebar view entries

Each view in the sidebar now shows a count chip displaying the number of
notes matching its filters, consistent with type section count chips.
Count updates reactively as vault entries change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-06 15:29:54 +02:00
parent 80914e1e3c
commit f02bb68021
2 changed files with 72 additions and 3 deletions

View File

@@ -987,4 +987,67 @@ describe('Sidebar', () => {
expect(onCreateNewType).toHaveBeenCalledOnce()
})
})
describe('view note count chips', () => {
const mockViews = [
{
filename: 'active-projects.yml',
definition: {
name: 'Active Projects',
icon: '🚀',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
},
},
{
filename: 'all-topics.yml',
definition: {
name: 'All Topics',
icon: null,
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Topic' }] },
},
},
]
it('shows note count chip for each view matching the filter results', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />
)
// 'Active Projects' filters for type=Project -> mockEntries has 1 Project (build-app.md)
const projectLabel = screen.getByText('Active Projects')
const projectNavItem = projectLabel.closest('[class*="cursor-pointer"]')!
// The count chip is a sibling span inside the NavItem
const projectCount = projectNavItem.querySelector('span:last-child')
expect(projectCount?.textContent).toBe('1')
// 'All Topics' filters for type=Topic -> mockEntries has 2 Topics
const topicLabel = screen.getByText('All Topics')
const topicNavItem = topicLabel.closest('[class*="cursor-pointer"]')!
const topicCount = topicNavItem.querySelector('span:last-child')
expect(topicCount?.textContent).toBe('2')
})
it('does not show count chip for views with 0 matching notes', () => {
const emptyView = [{
filename: 'empty.yml',
definition: {
name: 'Empty View',
icon: null,
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Nonexistent' }] },
},
}]
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={emptyView} />
)
expect(screen.getByText('Empty View')).toBeInTheDocument()
// No count chip rendered for 0 results (NavItem hides count <= 0)
const viewContainer = screen.getByText('Empty View').closest('div')
expect(viewContainer?.querySelector('span:last-child')?.textContent).not.toBe('0')
})
})
})

View File

@@ -15,6 +15,7 @@ import {
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
} from '@phosphor-icons/react'
import { isEmoji } from '../utils/emoji'
import { evaluateView } from '../utils/viewFilters'
import { arrayMove } from '@dnd-kit/sortable'
import { SlidersHorizontal } from 'lucide-react'
import {
@@ -169,19 +170,22 @@ function SidebarGroupHeader({ label, collapsed, onToggle, count, children }: {
)
}
function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView }: {
function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView, entries }: {
view: ViewFile
isActive: boolean
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
entries: VaultEntry[]
}) {
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
return (
<div className="group relative">
<NavItem
icon={Funnel}
emoji={view.definition.icon}
label={view.definition.name}
count={count}
isActive={isActive}
onClick={onSelect}
/>
@@ -209,7 +213,7 @@ function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView }: {
)
}
function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreateView, onEditView, onDeleteView }: {
function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreateView, onEditView, onDeleteView, entries }: {
views: ViewFile[]
selection: SidebarSelection
onSelect: (sel: SidebarSelection) => void
@@ -218,6 +222,7 @@ function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreat
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
entries: VaultEntry[]
}) {
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
@@ -240,6 +245,7 @@ function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreat
onSelect={() => onSelect({ kind: 'view', filename: v.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
/>
))}
</div>
@@ -581,7 +587,7 @@ export const Sidebar = memo(function Sidebar({
{/* Views */}
{hasViews && (
<ViewsSection views={views} selection={selection} onSelect={onSelect} collapsed={groupCollapsed.views} onToggle={() => toggleGroup('views')} onCreateView={onCreateView} onEditView={onEditView} onDeleteView={onDeleteView} />
<ViewsSection views={views} selection={selection} onSelect={onSelect} collapsed={groupCollapsed.views} onToggle={() => toggleGroup('views')} onCreateView={onCreateView} onEditView={onEditView} onDeleteView={onDeleteView} entries={entries} />
)}
{/* Types */}