feat: add filter pills (Open/Archived/Trashed) with count badges to note list

When viewing a type in the sidebar, the note list now shows filter pills
below the header to switch between Open, Archived, and Trashed notes.
Each pill shows a count badge. Bulk actions are context-aware: Trashed
filter offers Restore/Archive/Delete permanently, Archived filter offers
Unarchive/Trash. Cmd+K commands added for switching filters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-18 03:43:23 +01:00
parent b846de15ad
commit 2b786892ee
11 changed files with 458 additions and 165 deletions

View File

@@ -51,7 +51,7 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import { filterEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import { flushEditorContent } from './utils/autoSave'
import './App.css'
@@ -70,6 +70,11 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
const handleSetSelection = useCallback((sel: SidebarSelection) => {
setSelection(sel)
setNoteListFilter('open')
}, [])
const layout = useLayoutPanels()
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
@@ -78,7 +83,7 @@ function App() {
// called on user interaction, never during render (refs inside the hook
// guarantee the latest closure is always used).
const vaultSwitcher = useVaultSwitcher({
onSwitch: () => { setSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onToast: (msg) => setToastMessage(msg),
})
@@ -199,7 +204,7 @@ function App() {
onOpenNote: openNoteByPath,
onOpenTab: openNoteByPath,
onSetFilter: (filterType) => {
setSelection({ kind: 'sectionGroup', type: filterType })
handleSetSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: () => { vault.reloadVault() },
})
@@ -441,7 +446,7 @@ function App() {
onToggleRawEditor: () => rawToggleRef.current(),
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
onSelect: handleSetSelection, onCloseTab: notes.handleCloseTab,
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
onGoBack: handleGoBack, onGoForward: handleGoForward,
@@ -451,7 +456,7 @@ function App() {
onCreateTheme: async () => {
const path = await themeManager.createTheme()
const freshEntries = await vault.reloadVault()
setSelection({ kind: 'sectionGroup', type: 'Theme' })
handleSetSelection({ kind: 'sectionGroup', type: 'Theme' })
if (path) {
const entry = freshEntries.find(e => e.path === path)
if (entry) notes.handleSelectNote(entry)
@@ -484,6 +489,8 @@ function App() {
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
return !!(ae?.icon && isEmoji(ae.icon))
})(),
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
@@ -517,7 +524,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
<Sidebar entries={vault.entries} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -528,7 +535,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -600,7 +607,7 @@ function App() {
/>
)}
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />

View File

@@ -21,11 +21,11 @@ describe('BulkActionBar', () => {
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
})
it('shows Restore and Delete permanently in trash view', () => {
it('shows Restore, Archive, and Delete permanently in trash view', () => {
render(<BulkActionBar {...defaultProps} isTrashView={true} />)
expect(screen.getByTestId('bulk-restore-btn')).toBeInTheDocument()
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
expect(screen.queryByTestId('bulk-trash-btn')).not.toBeInTheDocument()
})
@@ -47,4 +47,20 @@ describe('BulkActionBar', () => {
render(<BulkActionBar {...defaultProps} count={5} />)
expect(screen.getByText('5 selected')).toBeInTheDocument()
})
it('shows Unarchive and Trash buttons in archived view', () => {
render(<BulkActionBar {...defaultProps} isArchivedView={true} />)
expect(screen.getByTestId('bulk-unarchive-btn')).toBeInTheDocument()
expect(screen.getByTestId('bulk-trash-btn')).toBeInTheDocument()
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
expect(screen.queryByTestId('bulk-restore-btn')).not.toBeInTheDocument()
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
})
it('calls onUnarchive when Unarchive button clicked in archived view', () => {
const onUnarchive = vi.fn()
render(<BulkActionBar {...defaultProps} isArchivedView={true} onUnarchive={onUnarchive} />)
fireEvent.click(screen.getByTestId('bulk-unarchive-btn'))
expect(onUnarchive).toHaveBeenCalledTimes(1)
})
})

View File

@@ -4,17 +4,61 @@ import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
interface BulkActionBarProps {
count: number
isTrashView: boolean
isArchivedView?: boolean
onArchive: () => void
onTrash: () => void
onRestore: () => void
onDeletePermanently: () => void
onUnarchive?: () => void
onClear: () => void
}
const actionBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 } as const
const destructiveBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 } as const
function BulkActionBarInner({ count, isTrashView, onArchive, onTrash, onRestore, onDeletePermanently, onClear }: BulkActionBarProps) {
function renderTrashActions(onRestore: () => void, onArchive: () => void, onDeletePermanently: () => void) {
return (
<>
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onRestore} title="Restore selected notes" data-testid="bulk-restore-btn">
<ArrowCounterClockwise size={14} /> Restore
</button>
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
<Archive size={14} /> Archive
</button>
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDeletePermanently} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
<Trash size={14} /> Delete permanently
</button>
</>
)
}
function renderArchivedActions(onUnarchive: (() => void) | undefined, onTrash: () => void) {
return (
<>
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onUnarchive} title="Unarchive selected notes" data-testid="bulk-unarchive-btn">
<ArrowCounterClockwise size={14} /> Unarchive
</button>
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onTrash} title="Move selected notes to trash" data-testid="bulk-trash-btn">
<Trash size={14} /> Trash
</button>
</>
)
}
function renderDefaultActions(onArchive: () => void, onTrash: () => void) {
return (
<>
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
<Archive size={14} /> Archive
</button>
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onTrash} title="Move selected notes to trash" data-testid="bulk-trash-btn">
<Trash size={14} /> Trash
</button>
</>
)
}
function BulkActionBarInner({ count, isTrashView, isArchivedView, onArchive, onTrash, onRestore, onDeletePermanently, onUnarchive, onClear }: BulkActionBarProps) {
return (
<div
className="flex shrink-0 items-center justify-between"
@@ -30,53 +74,9 @@ function BulkActionBarInner({ count, isTrashView, onArchive, onTrash, onRestore,
{count} selected
</span>
<div className="flex items-center gap-1">
{isTrashView ? (
<>
<button
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
style={actionBtnStyle}
onClick={onRestore}
title="Restore selected notes"
data-testid="bulk-restore-btn"
>
<ArrowCounterClockwise size={14} />
Restore
</button>
<button
className="flex items-center gap-1.5 border-none cursor-pointer"
style={destructiveBtnStyle}
onClick={onDeletePermanently}
title="Permanently delete selected notes"
data-testid="bulk-delete-btn"
>
<Trash size={14} />
Delete permanently
</button>
</>
) : (
<>
<button
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
style={actionBtnStyle}
onClick={onArchive}
title="Archive selected notes"
data-testid="bulk-archive-btn"
>
<Archive size={14} />
Archive
</button>
<button
className="flex items-center gap-1.5 border-none cursor-pointer"
style={destructiveBtnStyle}
onClick={onTrash}
title="Move selected notes to trash"
data-testid="bulk-trash-btn"
>
<Trash size={14} />
Trash
</button>
</>
)}
{isTrashView ? renderTrashActions(onRestore, onArchive, onDeletePermanently)
: isArchivedView ? renderArchivedActions(onUnarchive, onTrash)
: renderDefaultActions(onArchive, onTrash)}
<button
className="flex items-center border-none bg-transparent cursor-pointer"
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}

View File

@@ -1,12 +1,15 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NoteList } from './NoteList'
import { getSortComparator, filterEntries } from '../utils/noteListHelpers'
import { getSortComparator, filterEntries, countByFilter } from '../utils/noteListHelpers'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
const noopSelect = vi.fn()
const noopReplace = vi.fn()
const noopFilterChange = vi.fn()
const defaultFilterProps = { noteListFilter: 'open' as NoteListFilter, onNoteListFilterChange: noopFilterChange }
const mockEntries: VaultEntry[] = [
{
@@ -153,38 +156,38 @@ const mockEntries: VaultEntry[] = [
describe('NoteList', () => {
it('shows empty state when no entries', () => {
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('No notes found')).toBeInTheDocument()
})
it('renders all entries with All Notes filter', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
})
it('filters by People (section group)', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by Events (section group)', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by section group type', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows entity pinned at top with grouped children', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Entity title appears in header and pinned card
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
@@ -199,7 +202,7 @@ describe('NoteList', () => {
it('filters by topic (relatedTo references)', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Build Laputa App has relatedTo: [[topic/software-development]]
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
@@ -207,7 +210,7 @@ describe('NoteList', () => {
})
it('shows search input when search icon is clicked', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Search is hidden by default
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
// Click search icon to show it
@@ -216,7 +219,7 @@ describe('NoteList', () => {
})
it('filters by search query (case-insensitive substring)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Open search
fireEvent.click(screen.getByTitle('Search notes'))
const input = screen.getByPlaceholderText('Search notes...')
@@ -231,21 +234,21 @@ describe('NoteList', () => {
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
]
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
const titles = screen.getAllByText(/Oldest|Newest|Middle/)
const titleTexts = titles.map((el) => el.textContent)
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
})
it('does not render type badge or status on note items', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
expect(screen.queryByText('Active')).not.toBeInTheDocument()
})
it('header shows search and plus icons instead of count badge', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
})
@@ -255,7 +258,7 @@ describe('NoteList', () => {
e.path === mockEntries[2].path ? { ...e, outgoingLinks: ['Build Laputa App'] } : e
)
render(
<NoteList entries={entriesWithBacklink} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entriesWithBacklink} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
@@ -263,7 +266,7 @@ describe('NoteList', () => {
it('context view collapses and expands groups', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Children group is expanded by default
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -278,7 +281,7 @@ describe('NoteList', () => {
it('context view shows prominent card with snippet subtitle', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Snippet text appears in the prominent card
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
@@ -292,21 +295,21 @@ describe('NoteList click behavior', () => {
})
it('regular click calls onReplaceActiveTab (opens in current tab)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'))
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
expect(noopSelect).not.toHaveBeenCalled()
})
it('Cmd+Click calls onSelectNote (opens in new tab)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
expect(noopReplace).not.toHaveBeenCalled()
})
it('Ctrl+Click calls onSelectNote (Windows/Linux)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
expect(noopReplace).not.toHaveBeenCalled()
@@ -314,7 +317,7 @@ describe('NoteList click behavior', () => {
it('Cmd+Click on entity pinned card calls onSelectNote', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1], { metaKey: true })
@@ -324,7 +327,7 @@ describe('NoteList click behavior', () => {
it('regular click on entity pinned card calls onReplaceActiveTab', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
const titles = screen.getAllByText('Build Laputa App')
@@ -335,7 +338,7 @@ describe('NoteList click behavior', () => {
it('click on child note in entity view calls onReplaceActiveTab', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
expect(noopReplace).toHaveBeenCalledWith(mockEntries[1])
@@ -489,21 +492,21 @@ describe('NoteList sort controls', () => {
it('shows sort button in note list header for flat view', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
})
it('shows sort dropdown per relationship subsection in entity view', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
})
it('opens sort menu on click and shows all options', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
@@ -520,7 +523,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort: by modified (Zebra first)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
@@ -537,7 +540,7 @@ describe('NoteList sort controls', () => {
it('closes sort menu after selecting an option', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
@@ -547,7 +550,7 @@ describe('NoteList sort controls', () => {
it('shows direction arrows in sort dropdown menu', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
// Each option should have asc and desc direction buttons
@@ -564,7 +567,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort: modified descending (Zebra first at 3000)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
@@ -585,7 +588,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Select title sort with desc direction
fireEvent.click(screen.getByTestId('sort-button-__list__'))
@@ -598,7 +601,7 @@ describe('NoteList sort controls', () => {
it('shows direction icon on the sort button that reflects current direction', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default: modified desc → should have ArrowDown icon
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
@@ -635,7 +638,7 @@ describe('NoteList sort controls', () => {
const entries = [parent, child1, child2]
render(
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort: by modified — Zebra Note (3000) before Alpha Note (1000)
@@ -657,7 +660,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-separator')).toBeInTheDocument()
@@ -668,7 +671,7 @@ describe('NoteList sort controls', () => {
it('omits separator when no custom properties exist', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument()
@@ -681,7 +684,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default: modified desc → A, B, C
let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
@@ -703,7 +706,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-property:Priority'))
@@ -821,7 +824,7 @@ describe('NoteList — status indicators', () => {
it('shows modified indicator dot for modified notes', () => {
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
)
const indicators = screen.getAllByTestId('modified-indicator')
expect(indicators).toHaveLength(1)
@@ -832,7 +835,7 @@ describe('NoteList — status indicators', () => {
it('does not show indicator when all notes are clean', () => {
const getNoteStatus = () => 'clean' as const
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
@@ -842,14 +845,14 @@ describe('NoteList — status indicators', () => {
const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path])
const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
)
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
})
it('does not show indicator when getNoteStatus prop is undefined', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
@@ -858,7 +861,7 @@ describe('NoteList — status indicators', () => {
it('shows green new indicator for new notes', () => {
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
)
expect(screen.getAllByTestId('new-indicator')).toHaveLength(1)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
@@ -869,31 +872,31 @@ describe('NoteList — trash view', () => {
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
it('shows "Trash" header when trash filter is active', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Trash')).toBeInTheDocument()
})
it('shows only trashed entries in trash view', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('shows TRASHED badge on trashed entries', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
const badges = screen.getAllByText('TRASHED')
expect(badges.length).toBeGreaterThanOrEqual(1)
})
it('shows 30-day warning banner when expired notes exist', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
})
it('shows "Trash is empty" when no trashed entries', () => {
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
})
})
@@ -933,7 +936,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i))
const { container } = render(
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Virtuoso mock renders all items; the real component only renders visible ones
expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument()
@@ -942,7 +945,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('renders items from a large dataset via Virtuoso', () => {
const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i))
render(
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Note 0')).toBeInTheDocument()
expect(screen.getByText('Note 499')).toBeInTheDocument()
@@ -955,7 +958,7 @@ describe('NoteList — virtual list with large datasets', () => {
makeEntry(999, { title: 'Beta Strategy' }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
@@ -971,7 +974,7 @@ describe('NoteList — virtual list with large datasets', () => {
...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort is modified desc — Alpha (3000) should come first
const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0]
@@ -984,7 +987,7 @@ describe('NoteList — virtual list with large datasets', () => {
...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
]
render(
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Project 0')).toBeInTheDocument()
expect(screen.queryByText('Note 0')).not.toBeInTheDocument()
@@ -994,7 +997,7 @@ describe('NoteList — virtual list with large datasets', () => {
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
const selected = entries[5]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Note 5')).toBeInTheDocument()
})
@@ -1003,7 +1006,7 @@ describe('NoteList — virtual list with large datasets', () => {
noopReplace.mockClear()
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByText('Note 50'))
expect(noopReplace).toHaveBeenCalledWith(entries[50])
@@ -1018,7 +1021,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('shows only modified notes in changes view', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -1028,21 +1031,21 @@ describe('NoteList — virtual list with large datasets', () => {
it('shows header title "Changes"', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Changes')).toBeInTheDocument()
})
it('shows empty state when no modified files', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('No pending changes')).toBeInTheDocument()
})
it('updates list when modifiedFiles changes', () => {
const { rerender } = render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -1050,7 +1053,7 @@ describe('NoteList — virtual list with large datasets', () => {
// Simulate one file being committed (removed from modifiedFiles)
const fewerModified = [modifiedFiles[0]]
rerender(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
@@ -1061,7 +1064,7 @@ describe('NoteList — virtual list with large datasets', () => {
// The changes filter must use modifiedFiles for filtering even when getNoteStatus is present.
const getNoteStatus = (path: string) => modifiedFiles.some((f) => f.path === path) ? 'modified' as const : 'clean' as const
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -1079,7 +1082,7 @@ describe('NoteList — virtual list with large datasets', () => {
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
]
render(
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Even though absolute paths differ, entries should match via relative path suffix
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
@@ -1089,7 +1092,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('shows error message when modifiedFilesError is set', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
@@ -1101,7 +1104,7 @@ describe('NoteList — virtual list with large datasets', () => {
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
@@ -1115,7 +1118,7 @@ describe('NoteList — virtual list with large datasets', () => {
{ path: '/Users/luca/Laputa/note/also-gone.md', relativePath: 'note/also-gone.md', status: 'deleted' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('2 notes deleted')).toBeInTheDocument()
@@ -1126,14 +1129,14 @@ describe('NoteList — virtual list with large datasets', () => {
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithOneDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithOneDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('1 note deleted')).toBeInTheDocument()
})
it('does not show deleted banner when no files are deleted', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
@@ -1143,7 +1146,7 @@ describe('NoteList — virtual list with large datasets', () => {
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
]
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
@@ -1159,7 +1162,7 @@ describe('NoteList — multi-select', () => {
})
it('Shift+Click selects a range of notes', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Regular click to set anchor
fireEvent.click(screen.getByText('Build Laputa App'))
// Shift+Click to select range
@@ -1170,7 +1173,7 @@ describe('NoteList — multi-select', () => {
})
it('regular click clears multi-select and opens note', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Select range via Shift+click
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
@@ -1182,7 +1185,7 @@ describe('NoteList — multi-select', () => {
})
it('Cmd+Click clears multi-select and opens in new tab', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Select range via Shift+click
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
@@ -1194,7 +1197,7 @@ describe('NoteList — multi-select', () => {
})
it('shows bulk action bar with correct count', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
@@ -1203,7 +1206,7 @@ describe('NoteList — multi-select', () => {
it('bulk archive calls onBulkArchive and clears selection', () => {
const onBulkArchive = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
@@ -1213,7 +1216,7 @@ describe('NoteList — multi-select', () => {
it('bulk trash calls onBulkTrash and clears selection', () => {
const onBulkTrash = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
@@ -1222,7 +1225,7 @@ describe('NoteList — multi-select', () => {
})
it('clear button on bulk action bar clears selection', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
@@ -1233,7 +1236,7 @@ describe('NoteList — multi-select', () => {
it('Cmd+E archives selected notes when multiselect is active', () => {
const onBulkArchive = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
@@ -1244,7 +1247,7 @@ describe('NoteList — multi-select', () => {
it('Cmd+Backspace trashes selected notes when multiselect is active', () => {
const onBulkTrash = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
@@ -1255,7 +1258,7 @@ describe('NoteList — multi-select', () => {
it('Cmd+Delete trashes selected notes when multiselect is active', () => {
const onBulkTrash = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
@@ -1264,7 +1267,7 @@ describe('NoteList — multi-select', () => {
})
it('no bulk action bar when nothing is selected', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
})
@@ -1309,7 +1312,7 @@ describe('NoteList — type note filtering', () => {
it('does not show type note PinnedCard when browsing a sectionGroup', () => {
render(
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// The type note snippet should NOT be visible (PinnedCard was removed)
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
@@ -1319,7 +1322,7 @@ describe('NoteList — type note filtering', () => {
it('shows clickable header title that navigates to type note', () => {
render(
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
const headerLink = screen.getByTestId('type-header-link')
expect(headerLink).toBeInTheDocument()
@@ -1331,7 +1334,7 @@ describe('NoteList — type note filtering', () => {
it('header is not clickable when not viewing a type section', () => {
render(
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
})
@@ -1340,7 +1343,7 @@ describe('NoteList — type note filtering', () => {
describe('NoteList — traffic light padding when sidebar collapsed', () => {
it('adds left padding to header when sidebarCollapsed is true', () => {
const { container } = render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={true} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={true} onCreateNote={vi.fn()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('80px')
@@ -1348,7 +1351,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
it('does not add extra left padding when sidebarCollapsed is false', () => {
const { container } = render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={false} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={false} onCreateNote={vi.fn()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
@@ -1356,9 +1359,173 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
it('does not add extra left padding when sidebarCollapsed is not provided', () => {
const { container } = render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
})
})
describe('countByFilter', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
...overrides,
})
it('counts open, archived, and trashed notes per type', () => {
const entries = [
makeEntry({ path: '/1.md', isA: 'Project' }),
makeEntry({ path: '/2.md', isA: 'Project', archived: true }),
makeEntry({ path: '/3.md', isA: 'Project', trashed: true }),
makeEntry({ path: '/4.md', isA: 'Project' }),
makeEntry({ path: '/5.md', isA: 'Note' }),
]
const counts = countByFilter(entries, 'Project')
expect(counts).toEqual({ open: 2, archived: 1, trashed: 1 })
})
it('returns zeros when type has no entries', () => {
expect(countByFilter([], 'Project')).toEqual({ open: 0, archived: 0, trashed: 0 })
})
it('counts trashed note that is also archived as trashed only', () => {
const entries = [
makeEntry({ path: '/1.md', isA: 'Project', archived: true, trashed: true }),
]
const counts = countByFilter(entries, 'Project')
expect(counts).toEqual({ open: 0, archived: 0, trashed: 1 })
})
})
describe('NoteList — filter pills', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
...overrides,
})
const projectEntries = [
makeEntry({ path: '/p1.md', title: 'Open Project 1', isA: 'Project' }),
makeEntry({ path: '/p2.md', title: 'Open Project 2', isA: 'Project' }),
makeEntry({ path: '/p3.md', title: 'Archived Project', isA: 'Project', archived: true }),
makeEntry({ path: '/p4.md', title: 'Trashed Project', isA: 'Project', trashed: true, trashedAt: 1700000000 }),
makeEntry({ path: '/n1.md', title: 'Some Note', isA: 'Note' }),
]
it('shows filter pills when a type is selected', () => {
render(
<NoteList {...defaultFilterProps} entries={projectEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
})
it('does not show filter pills in All Notes view', () => {
render(
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('filter-pills')).not.toBeInTheDocument()
})
it('shows correct count badges for each filter', () => {
render(
<NoteList {...defaultFilterProps} entries={projectEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Open pill should show 2, Archived 1, Trashed 1
const openPill = screen.getByTestId('filter-pill-open')
const archivedPill = screen.getByTestId('filter-pill-archived')
const trashedPill = screen.getByTestId('filter-pill-trashed')
expect(openPill).toHaveTextContent('Open')
expect(openPill).toHaveTextContent('2')
expect(archivedPill).toHaveTextContent('Archived')
expect(archivedPill).toHaveTextContent('1')
expect(trashedPill).toHaveTextContent('Trashed')
expect(trashedPill).toHaveTextContent('1')
})
it('calls onNoteListFilterChange when a pill is clicked', () => {
const onFilterChange = vi.fn()
render(
<NoteList noteListFilter="open" onNoteListFilterChange={onFilterChange} entries={projectEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('filter-pill-archived'))
expect(onFilterChange).toHaveBeenCalledWith('archived')
})
it('shows archived notes when filter is set to archived', () => {
render(
<NoteList noteListFilter="archived" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Archived Project')).toBeInTheDocument()
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
})
it('shows trashed notes when filter is set to trashed', () => {
render(
<NoteList noteListFilter="trashed" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Trashed Project')).toBeInTheDocument()
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
})
it('shows empty state for archived filter when no archived notes exist', () => {
const entriesNoArchived = projectEntries.filter(e => !e.archived)
render(
<NoteList noteListFilter="archived" onNoteListFilterChange={noopFilterChange} entries={entriesNoArchived} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('No archived notes')).toBeInTheDocument()
})
})
describe('NoteList — filterEntries with subFilter', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
...overrides,
})
const entries = [
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
makeEntry({ path: '/3.md', title: 'Trashed', isA: 'Project', trashed: true }),
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
]
it('filters sectionGroup by open sub-filter', () => {
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'open')
expect(result.map(e => e.title)).toEqual(['Active'])
})
it('filters sectionGroup by archived sub-filter', () => {
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'archived')
expect(result.map(e => e.title)).toEqual(['Archived'])
})
it('filters sectionGroup by trashed sub-filter', () => {
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'trashed')
expect(result.map(e => e.title)).toEqual(['Trashed'])
})
it('without sub-filter, defaults to active only', () => {
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
expect(result.map(e => e.title)).toEqual(['Active'])
})
})

View File

@@ -1,11 +1,14 @@
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import { countByFilter } from '../utils/noteListHelpers'
import { NoteItem } from './NoteItem'
import { prefetchNoteContent } from '../hooks/useTabManagement'
import { BulkActionBar } from './BulkActionBar'
import { useMultiSelect } from '../hooks/useMultiSelect'
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
import { NoteListHeader } from './note-list/NoteListHeader'
import { FilterPills } from './note-list/FilterPills'
import { EntityView, ListView } from './note-list/NoteListViews'
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
@@ -18,6 +21,8 @@ interface NoteListProps {
entries: VaultEntry[]
selection: SidebarSelection
selectedNote: VaultEntry | null
noteListFilter: NoteListFilter
onNoteListFilterChange: (filter: NoteListFilter) => void
modifiedFiles?: ModifiedFile[]
modifiedFilesError?: string | null
getNoteStatus?: (path: string) => NoteStatus
@@ -34,14 +39,23 @@ interface NoteListProps {
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
const isSectionGroup = selection.kind === 'sectionGroup'
const subFilter = isSectionGroup ? noteListFilter : undefined
const filterCounts = useMemo(
() => isSectionGroup ? countByFilter(entries, selection.type) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, selection],
)
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry })
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const typeEntryMap = useTypeEntryMap(entries)
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const deletedCount = useMemo(
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
@@ -61,7 +75,8 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : handleBulkArchive
const handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : isArchivedView ? handleBulkUnarchive : handleBulkArchive
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
@@ -75,18 +90,19 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
{isSectionGroup && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
<div className="flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
<ListView isTrashView={isTrashView} isArchivedView={isArchivedView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
</div>
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onClear={multiSelect.clear} />
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
)}
</div>
)

View File

@@ -0,0 +1,43 @@
import { memo } from 'react'
import type { NoteListFilter } from '../../utils/noteListHelpers'
interface FilterPillsProps {
active: NoteListFilter
counts: Record<NoteListFilter, number>
onChange: (filter: NoteListFilter) => void
}
const PILLS: { value: NoteListFilter; label: string }[] = [
{ value: 'open', label: 'Open' },
{ value: 'archived', label: 'Archived' },
{ value: 'trashed', label: 'Trashed' },
]
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
return (
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
{PILLS.map(({ value, label }) => (
<button
key={value}
type="button"
role="tab"
aria-selected={active === value}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
active === value
? 'border-foreground/20 bg-foreground/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
onClick={() => onChange(value)}
data-testid={`filter-pill-${value}`}
>
{label}
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
{counts[value]}
</span>
</button>
))}
</div>
)
}
export const FilterPills = memo(FilterPillsInner)

View File

@@ -11,10 +11,11 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, query: string): string {
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, query: string): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isTrashView) return 'Trash is empty'
if (isArchivedView) return 'No archived notes'
return query ? 'No matching notes' : 'No notes found'
}
@@ -38,13 +39,13 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
)
}
export function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
export function ListView({ isTrashView, isArchivedView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
deletedCount?: number; searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, query)
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, query)
const hasHeader = isTrashView && expiredTrashCount > 0
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0

View File

@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types'
import {
type SortOption, type SortDirection, type SortConfig,
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
getSortComparator, extractSortableProperties,
buildRelationshipGroups, filterEntries,
loadSortPreferences, saveSortPreferences,
@@ -19,14 +19,14 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
// --- useFilteredEntries ---
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[]) {
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter) {
const isEntityView = selection.kind === 'entity'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
return useMemo(() => {
if (isEntityView) return []
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
return filterEntries(entries, selection)
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
return filterEntries(entries, selection, subFilter)
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes, subFilter])
}
// --- useNoteListData ---
@@ -35,13 +35,15 @@ interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
subFilter?: NoteListFilter
}
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
const searched = useMemo(() => {
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
@@ -59,7 +61,7 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
[isTrashView, searched],
)
return { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount }
return { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount }
}
// --- useNoteListSearch ---
@@ -122,11 +124,12 @@ export interface UseNoteListSortParams {
selection: SidebarSelection
modifiedPathSet: Set<string>
modifiedSuffixes: string[]
subFilter?: NoteListFilter
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const typeDocument = useMemo(() => {
@@ -154,7 +157,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
}
}, [typeDocument, persistence])
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'

View File

@@ -5,6 +5,7 @@ import type { CommandAction } from './useCommandRegistry'
import { useKeyboardNavigation } from './useKeyboardNavigation'
import { useMenuEvents } from './useMenuEvents'
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
interface Tab { entry: VaultEntry; content: string }
@@ -74,6 +75,8 @@ interface AppCommandsConfig {
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
activeNoteHasIcon?: boolean
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -223,6 +226,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onSetNoteIcon: config.onSetNoteIcon,
onRemoveNoteIcon: config.onRemoveNoteIcon,
activeNoteHasIcon: config.activeNoteHasIcon,
selection: config.selection,
noteListFilter: config.noteListFilter,
onSetNoteListFilter: config.onSetNoteListFilter,
})
useKeyboardNavigation({

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Appearance' | 'Settings'
@@ -71,6 +72,10 @@ interface CommandRegistryConfig {
onRestoreDefaultThemes?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
/** Current selection — used to scope filter pill commands to section group views. */
selection?: SidebarSelection
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
}
const PLURAL_OVERRIDES: Record<string, string> = {
@@ -212,8 +217,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onSetNoteIcon,
onRemoveNoteIcon,
activeNoteHasIcon,
selection, noteListFilter, onSetNoteListFilter,
} = config
const isSectionGroup = selection?.kind === 'sectionGroup'
const hasActiveNote = activeTabPath !== null
const activeEntry = useMemo(
@@ -292,6 +299,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
// Note list filter pills (scoped to section group views)
{ id: 'filter-open', label: 'Show Open Notes', group: 'Navigation', keywords: ['filter', 'open', 'active', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'open', execute: () => onSetNoteListFilter?.('open') },
{ id: 'filter-archived', label: 'Show Archived Notes', group: 'Navigation', keywords: ['filter', 'archived', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'archived', execute: () => onSetNoteListFilter?.('archived') },
{ id: 'filter-trashed', label: 'Show Trashed Notes', group: 'Navigation', keywords: ['filter', 'trashed', 'trash', 'pill', 'deleted'], enabled: !!isSectionGroup && noteListFilter !== 'trashed', execute: () => onSetNoteListFilter?.('trashed') },
]
return cmds
@@ -310,5 +322,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onEmptyTrash, trashedCount,
onReindexVault, onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
isSectionGroup, noteListFilter, onSetNoteListFilter,
])
}

View File

@@ -1,5 +1,7 @@
import type { VaultEntry, SidebarSelection } from '../types'
export type NoteListFilter = 'open' | 'archived' | 'trashed'
export interface RelationshipGroup {
label: string
entries: VaultEntry[]
@@ -313,10 +315,17 @@ export function buildRelationshipGroups(
const isActive = (e: VaultEntry) => !e.archived && !e.trashed
function filterByKind(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): VaultEntry[] {
if (subFilter === 'archived') return entries.filter((e) => e.archived && !e.trashed)
if (subFilter === 'trashed') return entries.filter((e) => e.trashed)
return entries.filter(isActive)
}
function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter): VaultEntry[] {
if (selection.kind === 'entity') return []
if (selection.kind === 'sectionGroup') {
return entries.filter((e) => e.isA === selection.type && isActive(e))
const typeEntries = entries.filter((e) => e.isA === selection.type)
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
}
if (selection.kind === 'topic') {
return entries.filter((e) => refsMatch(e.relatedTo, selection.entry) && isActive(e))
@@ -332,6 +341,18 @@ function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[]
return []
}
export function filterEntries(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
return filterByKind(entries, selection)
export function filterEntries(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter): VaultEntry[] {
return filterByKind(entries, selection, subFilter)
}
/** Count notes per sub-filter for a given type. */
export function countByFilter(entries: VaultEntry[], type: string): Record<NoteListFilter, number> {
let open = 0, archived = 0, trashed = 0
for (const e of entries) {
if (e.isA !== type) continue
if (e.trashed) trashed++
else if (e.archived) archived++
else open++
}
return { open, archived, trashed }
}