refactor: split note list hotspots

This commit is contained in:
Test
2026-04-08 09:03:02 +02:00
parent 4f54849991
commit 0cf2467fbc
10 changed files with 1867 additions and 2765 deletions

View File

@@ -1,307 +1,76 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NoteItem } from './NoteItem'
import type { VaultEntry } from '../types'
import { makeEntry } from '../test-utils/noteListTestUtils'
vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: vi.fn() }))
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/test.md', filename: 'test.md', title: 'Test Note',
isA: 'Movie', aliases: [], belongsTo: [], relatedTo: [],
status: null, archived: false,
modifiedAt: 1700000000, createdAt: null, fileSize: 100,
snippet: 'A snippet', wordCount: 50,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
visible: null, favorite: false, favoriteIndex: null,
outgoingLinks: [], properties: {},
listPropertiesDisplay: [],
...overrides,
}
}
function makeTypeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return makeEntry({
path: '/vault/movie.md', filename: 'movie.md', title: 'Movie',
isA: 'Type', listPropertiesDisplay: [],
...overrides,
})
}
const noop = vi.fn()
describe('NoteItem property chips', () => {
it('renders property chips when type has listPropertiesDisplay', () => {
const entry = makeEntry({
properties: { rating: 4, genre: 'Drama' },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.getByTestId('property-chips')).toBeInTheDocument()
expect(screen.getByText('4')).toBeInTheDocument()
expect(screen.getByText('Drama')).toBeInTheDocument()
})
it('does not render chips when listPropertiesDisplay is empty', () => {
const entry = makeEntry({ properties: { rating: 4 } })
const typeEntry = makeTypeEntry({ listPropertiesDisplay: [] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
})
it('skips chips for missing properties', () => {
const entry = makeEntry({ properties: { rating: 4 } })
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.getByText('4')).toBeInTheDocument()
expect(screen.queryByText('genre')).not.toBeInTheDocument()
})
it('renders relationship values as display labels', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.getByText('Steven Spielberg')).toBeInTheDocument()
})
it('shows the target note icon as a prefix on relationship chips', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const director = makeEntry({
path: '/vault/spielberg.md',
filename: 'spielberg.md',
title: 'Steven Spielberg',
isA: 'Person',
icon: 'star',
})
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
const personType = makeTypeEntry({
path: '/vault/person.md',
filename: 'person.md',
title: 'Person',
icon: 'users',
})
render(
<NoteItem
entry={entry}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: movieType, Person: personType }}
allEntries={[entry, director, movieType, personType]}
onClickNote={noop}
/>
)
const chip = screen.getByText('Steven Spielberg').parentElement
expect(chip?.querySelector('svg')).toBeInTheDocument()
expect(chip?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
})
it('falls back to the target type icon when the target note has no icon', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const director = makeEntry({
path: '/vault/spielberg.md',
filename: 'spielberg.md',
title: 'Steven Spielberg',
isA: 'Person',
icon: null,
})
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
const personType = makeTypeEntry({
path: '/vault/person.md',
filename: 'person.md',
title: 'Person',
icon: 'users',
})
render(
<NoteItem
entry={entry}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: movieType, Person: personType }}
allEntries={[entry, director, movieType, personType]}
onClickNote={noop}
/>
)
const chip = screen.getByText('Steven Spielberg').parentElement
expect(chip?.querySelector('svg')).toBeInTheDocument()
})
it('renders relationship chips without a prefix when neither note nor type has an icon', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const director = makeEntry({
path: '/vault/spielberg.md',
filename: 'spielberg.md',
title: 'Steven Spielberg',
isA: 'Person',
icon: null,
})
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
const personType = makeTypeEntry({
path: '/vault/person.md',
filename: 'person.md',
title: 'Person',
icon: null,
})
render(
<NoteItem
entry={entry}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: movieType, Person: personType }}
allEntries={[entry, director, movieType, personType]}
onClickNote={noop}
/>
)
const chip = screen.getByText('Steven Spielberg').parentElement
expect(chip?.querySelector('svg')).toBeNull()
expect(chip?.querySelector('img')).toBeNull()
})
it('falls back to the target type icon when the target note image icon fails to load', () => {
const entry = makeEntry({
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
})
const director = makeEntry({
path: '/vault/spielberg.md',
filename: 'spielberg.md',
title: 'Steven Spielberg',
isA: 'Person',
icon: 'https://example.com/director.png',
})
const movieType = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
const personType = makeTypeEntry({
path: '/vault/person.md',
filename: 'person.md',
title: 'Person',
icon: 'users',
})
render(
<NoteItem
entry={entry}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: movieType, Person: personType }}
allEntries={[entry, director, movieType, personType]}
onClickNote={noop}
/>
)
const chip = screen.getByText('Steven Spielberg').parentElement
const image = chip?.querySelector('img')
expect(image).toBeInTheDocument()
fireEvent.error(image!)
expect(chip?.querySelector('img')).toBeNull()
expect(chip?.querySelector('svg')).toBeInTheDocument()
})
it('shows hostname for URL properties', () => {
const entry = makeEntry({
properties: { url: 'https://www.example.com/page/123' },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['url'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
expect(screen.getByText('www.example.com')).toBeInTheDocument()
})
it('prefers displayPropsOverride over the type defaults', () => {
const entry = makeEntry({
properties: { rating: 4, Owner: 'Luca' },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] })
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} displayPropsOverride={['Owner']} onClickNote={noop} />
)
expect(screen.getByText('Luca')).toBeInTheDocument()
expect(screen.queryByText('4')).not.toBeInTheDocument()
})
it('does not render chips for binary files', () => {
const entry = makeEntry({
describe('NoteItem', () => {
it('renders binary files as non-clickable muted rows', () => {
const binaryEntry = makeEntry({
path: '/vault/photo.png',
filename: 'photo.png',
title: 'photo.png',
fileKind: 'binary',
properties: { rating: 4 },
})
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] })
const onClickNote = vi.fn()
render(
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
)
render(<NoteItem entry={binaryEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
})
})
describe('NoteItem note icons', () => {
it('renders a Phosphor note icon in the note row', () => {
render(
<NoteItem
entry={makeEntry({ icon: 'star' })}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: makeTypeEntry() }}
onClickNote={noop}
/>
)
expect(screen.getByTestId('note-title-icon').tagName.toLowerCase()).toBe('svg')
})
it('renders an image note icon in the note row', () => {
render(
<NoteItem
entry={makeEntry({ icon: 'https://example.com/favicon.png' })}
isSelected={false}
noteStatus="clean"
typeEntryMap={{ Movie: makeTypeEntry() }}
onClickNote={noop}
/>
)
const icon = screen.getByTestId('note-title-icon')
expect(icon.tagName.toLowerCase()).toBe('img')
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
const item = screen.getByTestId('binary-file-item')
expect(item.className).toContain('opacity-50')
expect(item).toHaveAttribute('title', 'Cannot open this file type')
fireEvent.click(item)
expect(onClickNote).not.toHaveBeenCalled()
})
it('renders text files as clickable rows', () => {
const textEntry = makeEntry({
path: '/vault/config.yml',
filename: 'config.yml',
title: 'config.yml',
fileKind: 'text',
})
const onClickNote = vi.fn()
render(<NoteItem entry={textEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
const item = screen.getByText('config.yml').closest('div')!
fireEvent.click(item)
expect(onClickNote).toHaveBeenCalled()
})
it('shows filenames instead of titles when a change status is present', () => {
const entry = makeEntry({ filename: 'my-note.md', title: 'My Note Title' })
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="modified" />)
expect(screen.getByText('my-note.md')).toBeInTheDocument()
expect(screen.queryByText('My Note Title')).not.toBeInTheDocument()
})
it('renders the correct symbol for modified files', () => {
const entry = makeEntry({ filename: 'note.md' })
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="modified" />)
expect(screen.getByTestId('change-status-icon').textContent).toBe('·')
})
it('renders the correct symbol for added files', () => {
const entry = makeEntry({ filename: 'new-note.md' })
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="added" />)
expect(screen.getByTestId('change-status-icon').textContent).toBe('+')
})
it('renders the regular title when no change status is set', () => {
const entry = makeEntry({ filename: 'note.md', title: 'My Note' })
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
expect(screen.getByText('My Note')).toBeInTheDocument()
expect(screen.queryByText('note.md')).not.toBeInTheDocument()
expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,257 @@
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
describe('NoteList status indicators', () => {
it('shows a modified indicator for modified notes', () => {
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const
renderNoteList({ getNoteStatus })
const indicators = screen.getAllByTestId('modified-indicator')
expect(indicators).toHaveLength(1)
const noteRow = indicators[0].closest('[data-testid="modified-indicator"]')!.parentElement!.parentElement!
expect(noteRow.textContent).toContain('Build Laputa App')
})
it('does not show indicators when everything is clean', () => {
renderNoteList({ getNoteStatus: () => 'clean' as const })
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
})
it('shows multiple modified indicators when multiple notes are dirty', () => {
const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path])
const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const
renderNoteList({ getNoteStatus })
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
})
it('does not show indicators when getNoteStatus is undefined', () => {
renderNoteList()
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
})
it('shows the green new indicator for new notes', () => {
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const
renderNoteList({ getNoteStatus })
expect(screen.getAllByTestId('new-indicator')).toHaveLength(1)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
})
})
describe('NoteList virtualized datasets', () => {
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
const largeDataset = Array.from({ length: 9000 }, (_, index) => makeIndexedEntry(index))
const { container } = renderNoteList({ entries: largeDataset })
expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument()
})
it('renders both ends of a large dataset through the Virtuoso mock', () => {
const largeDataset = Array.from({ length: 500 }, (_, index) => makeIndexedEntry(index))
renderNoteList({ entries: largeDataset })
expect(screen.getByText('Note 0')).toBeInTheDocument()
expect(screen.getByText('Note 499')).toBeInTheDocument()
})
it('filters large datasets by search query', () => {
const entries = [
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
...Array.from({ length: 998 }, (_, index) => makeIndexedEntry(index + 1, { title: `Filler Note ${index + 1}` })),
makeIndexedEntry(999, { title: 'Beta Strategy' }),
]
renderNoteList({ entries })
fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
})
it('sorts large datasets correctly', () => {
const entries = [
makeIndexedEntry(0, { title: 'Zebra', modifiedAt: 1000 }),
makeIndexedEntry(1, { title: 'Alpha', modifiedAt: 3000 }),
...Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index + 2, { title: `Mid ${index}`, modifiedAt: 2000 - index })),
]
renderNoteList({ entries })
expect(screen.getAllByText(/^Alpha$|^Zebra$/)[0].textContent).toBe('Alpha')
})
it('filters section groups inside large mixed datasets', () => {
const entries = [
...Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index, { isA: 'Project', title: `Project ${index}` })),
...Array.from({ length: 200 }, (_, index) => makeIndexedEntry(100 + index, { isA: 'Note', title: `Note ${index}` })),
]
renderNoteList({ entries, selection: { kind: 'sectionGroup', type: 'Project' } })
expect(screen.getByText('Project 0')).toBeInTheDocument()
expect(screen.queryByText('Note 0')).not.toBeInTheDocument()
})
it('keeps selection highlighting in virtualized lists', () => {
const entries = Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index))
renderNoteList({ entries, selectedNote: entries[5] })
expect(screen.getByText('Note 5')).toBeInTheDocument()
})
it('keeps click behavior working on virtualized items', () => {
const entries = Array.from({ length: 100 }, (_, index) => makeIndexedEntry(index))
const { onReplaceActiveTab } = renderNoteList({ entries })
fireEvent.click(screen.getByText('Note 50'))
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[50])
})
})
describe('NoteList multi-select', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function selectTwoNotes(extraProps: Record<string, unknown> = {}) {
renderNoteList(extraProps)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
}
it('selects a range on Shift+Click', () => {
selectTwoNotes()
expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(2)
})
it('clears multi-select and opens the note on regular click', () => {
const { onReplaceActiveTab } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByText('Matteo Cellini'))
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[2])
})
it('clears multi-select and opens a new tab on Cmd+Click', () => {
const { onSelectNote } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true })
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[2])
})
it('shows the bulk action bar with the selected count', () => {
selectTwoNotes()
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
expect(screen.getByText('2 selected')).toBeInTheDocument()
})
it.each([
{ label: 'archives via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) },
{ label: 'deletes via button', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.click(screen.getByTestId('bulk-delete-btn')) },
{ label: 'archives via Cmd+E', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) },
{ label: 'deletes via Cmd+Backspace', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) },
{ label: 'deletes via Cmd+Delete', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) },
])('bulk-select $label and clears the selection', ({ prop, trigger }) => {
const handler = vi.fn()
selectTwoNotes({ [prop]: handler })
trigger()
expect(handler).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
it('clears the selection from the bulk action bar', () => {
selectTwoNotes()
fireEvent.click(screen.getByTestId('bulk-clear-btn'))
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
})
it('does not show a bulk action bar when nothing is selected', () => {
renderNoteList()
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
})
describe('NoteList filter pills', () => {
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: '/n1.md', title: 'Some Note', isA: 'Note' }),
]
it('shows filter pills for type sections', () => {
renderNoteList({ entries: projectEntries, selection: { kind: 'sectionGroup', type: 'Project' } })
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
})
it('shows filter pills in all-notes view', () => {
renderNoteList({ entries: projectEntries })
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
})
it('shows the correct all-notes count badges', () => {
renderNoteList({ entries: projectEntries })
expect(screen.getByTestId('filter-pill-open')).toHaveTextContent('3')
expect(screen.getByTestId('filter-pill-archived')).toHaveTextContent('1')
})
it('shows archived notes in all-notes archived mode', () => {
renderNoteList({ entries: projectEntries, noteListFilter: 'archived' })
expect(screen.getByText('Archived Project')).toBeInTheDocument()
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
expect(screen.queryByText('Some Note')).not.toBeInTheDocument()
})
it('shows the correct counts for a type filter', () => {
renderNoteList({ entries: projectEntries, selection: { kind: 'sectionGroup', type: 'Project' } })
const openPill = screen.getByTestId('filter-pill-open')
const archivedPill = screen.getByTestId('filter-pill-archived')
expect(openPill).toHaveTextContent('Open')
expect(openPill).toHaveTextContent('2')
expect(archivedPill).toHaveTextContent('Archived')
expect(archivedPill).toHaveTextContent('1')
})
it('calls onNoteListFilterChange when a filter pill is clicked', () => {
const onNoteListFilterChange = vi.fn()
renderNoteList({
entries: projectEntries,
selection: { kind: 'sectionGroup', type: 'Project' },
onNoteListFilterChange,
})
fireEvent.click(screen.getByTestId('filter-pill-archived'))
expect(onNoteListFilterChange).toHaveBeenCalledWith('archived')
})
it('shows archived notes when the type filter switches to archived', () => {
renderNoteList({
entries: projectEntries,
selection: { kind: 'sectionGroup', type: 'Project' },
noteListFilter: 'archived',
})
expect(screen.getByText('Archived Project')).toBeInTheDocument()
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
})
it('shows the archived empty state when a section has no archived notes', () => {
renderNoteList({
entries: projectEntries.filter((entry) => !entry.archived),
selection: { kind: 'sectionGroup', type: 'Project' },
noteListFilter: 'archived',
})
expect(screen.getByText('No archived notes')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,236 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import type { SidebarSelection, VaultEntry } from '../types'
import { allSelection, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
const changesSelection: SidebarSelection = { kind: 'filter', filter: 'changes' }
const modifiedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
]
describe('NoteList changes view', () => {
it('shows only modified notes in changes view with filenames', () => {
renderNoteList({ selection: changesSelection, modifiedFiles })
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument()
})
it('shows the changes header title', () => {
renderNoteList({ selection: changesSelection, modifiedFiles })
expect(screen.getByText('Changes')).toBeInTheDocument()
})
it('shows an empty state when no modified files exist', () => {
renderNoteList({ selection: changesSelection, modifiedFiles: [] })
expect(screen.getByText('No pending changes')).toBeInTheDocument()
})
it('updates the list when modifiedFiles changes', () => {
const { rerender, props } = renderNoteList({ selection: changesSelection, modifiedFiles })
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
rerender(<NoteList {...props} modifiedFiles={[modifiedFiles[0]]} />)
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument()
})
it('uses modifiedFiles for filtering even when getNoteStatus is also provided', () => {
const getNoteStatus = (path: string) => modifiedFiles.some((file) => file.path === path) ? 'modified' as const : 'clean' as const
renderNoteList({ selection: changesSelection, modifiedFiles, getNoteStatus })
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument()
})
it('matches entries by relative path suffix across machines', () => {
const crossMachineEntries: VaultEntry[] = mockEntries.map((entry) => ({
...entry,
path: entry.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'),
}))
renderNoteList({
entries: crossMachineEntries,
selection: changesSelection,
modifiedFiles,
})
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument()
})
it('shows the load error when modifiedFilesError is set', () => {
renderNoteList({
selection: changesSelection,
modifiedFiles: [],
modifiedFilesError: 'git status failed: not a git repository',
})
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
})
it('shows untracked notes alongside modified notes', () => {
const mixedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
]
renderNoteList({ selection: changesSelection, modifiedFiles: mixedFiles })
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
expect(screen.getByText('matteo-cellini.md')).toBeInTheDocument()
expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument()
})
it('shows change-status icons for each modified file', () => {
const mixedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
]
renderNoteList({ selection: changesSelection, modifiedFiles: mixedFiles })
expect(screen.getAllByTestId('change-status-icon')).toHaveLength(2)
})
it('shows deleted notes as rows when files are deleted', () => {
const filesWithDeleted = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
{ path: '/Users/luca/Laputa/note/also-gone.md', relativePath: 'note/also-gone.md', status: 'deleted' as const },
]
renderNoteList({ selection: changesSelection, modifiedFiles: filesWithDeleted })
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
expect(screen.getByText('gone.md')).toBeInTheDocument()
expect(screen.getByText('also-gone.md')).toBeInTheDocument()
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
it('renders deleted rows with dimmed strikethrough styling', () => {
renderNoteList({
selection: changesSelection,
modifiedFiles: [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
],
})
expect(screen.getByText('gone.md')).toHaveClass('line-through')
expect(screen.getByText('gone.md')).toHaveClass('opacity-70')
})
it('does not show a deleted banner in or outside changes view', () => {
renderNoteList({ selection: changesSelection, modifiedFiles })
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
renderNoteList({
selection: allSelection,
modifiedFiles: [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
],
})
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
it('shows the discard context menu when onDiscardFile is provided', () => {
renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile: vi.fn() })
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument()
})
it('shows the restore action for deleted rows in the context menu', () => {
renderNoteList({
selection: changesSelection,
modifiedFiles: [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
],
onDiscardFile: vi.fn(),
})
const noteItem = screen.getByText('gone.md').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
expect(screen.getByTestId('restore-note-button')).toBeInTheDocument()
})
it('does not show a context menu when discard is unavailable', () => {
renderNoteList({ selection: changesSelection, modifiedFiles })
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
expect(screen.queryByTestId('changes-context-menu')).not.toBeInTheDocument()
})
it('shows the confirmation dialog after clicking discard changes', () => {
renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile: vi.fn() })
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
const dialog = screen.getByTestId('discard-confirm-dialog')
expect(dialog).toBeInTheDocument()
expect(dialog.textContent).toContain('Build Laputa App')
})
it('opens the restore context-menu action from Shift+F10 on highlighted deleted rows', () => {
renderNoteList({
selection: changesSelection,
modifiedFiles: [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
],
onDiscardFile: vi.fn(),
})
const container = screen.getByTestId('note-list-container')
act(() => {
fireEvent.focus(container)
})
act(() => {
fireEvent.keyDown(container, { key: 'F10', shiftKey: true })
})
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
expect(screen.getByTestId('restore-note-button')).toBeInTheDocument()
})
it('calls onDiscardFile with the relative path when discard is confirmed', async () => {
const onDiscardFile = vi.fn().mockResolvedValue(undefined)
renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile })
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
act(() => {
fireEvent.contextMenu(noteItem)
})
act(() => {
fireEvent.click(screen.getByTestId('discard-changes-button'))
})
await act(async () => {
fireEvent.click(screen.getByTestId('discard-confirm-button'))
await Promise.resolve()
})
expect(onDiscardFile).toHaveBeenCalledWith('project/26q1-laputa-app.md')
})
it('does not call onDiscardFile when cancel is clicked', () => {
const onDiscardFile = vi.fn()
renderNoteList({ selection: changesSelection, modifiedFiles, onDiscardFile })
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
fireEvent.click(screen.getByText('Cancel'))
expect(onDiscardFile).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,328 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents'
import {
allSelection,
makeEntry,
makeTypeDefinition,
mockEntries,
renderNoteList,
} from '../test-utils/noteListTestUtils'
describe('NoteList rendering', () => {
it('shows an empty state when there are no entries', () => {
renderNoteList({ entries: [] })
expect(screen.getByText('No notes found')).toBeInTheDocument()
})
it('renders all entries in the all-notes view', () => {
renderNoteList()
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
})
it('filters section groups by type', () => {
renderNoteList({ selection: { kind: 'sectionGroup', type: 'Person' } })
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('supports event sections', () => {
renderNoteList({ selection: { kind: 'sectionGroup', type: 'Event' } })
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('supports project sections', () => {
renderNoteList({ selection: { kind: 'sectionGroup', type: 'Project' } })
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('passes the selected type when creating a note from a type section', () => {
const { onCreateNote } = renderNoteList({ selection: { kind: 'sectionGroup', type: 'Project' } })
fireEvent.click(screen.getByTitle('Create new note'))
expect(onCreateNote).toHaveBeenCalledWith('Project')
})
it('creates an untyped note from all notes', () => {
const { onCreateNote } = renderNoteList()
fireEvent.click(screen.getByTitle('Create new note'))
expect(onCreateNote).toHaveBeenCalledWith(undefined)
})
it('pins the current entity and shows grouped children', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
expect(screen.getByText('Children')).toBeInTheDocument()
expect(screen.getByText('Related to')).toBeInTheDocument()
})
it('shows referenced-by groups for topic entities', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[4] } })
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Referenced By')).toBeInTheDocument()
})
it('toggles the search input from the header action', () => {
renderNoteList()
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
fireEvent.click(screen.getByTitle('Search notes'))
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
})
it('filters by a case-insensitive search query', () => {
renderNoteList()
fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'facebook' } })
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('sorts entries by last modified descending by default', () => {
renderNoteList({
entries: [
{ ...mockEntries[0], modifiedAt: 1000, title: 'Oldest' },
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
],
})
const titles = screen.getAllByText(/Oldest|Newest|Middle/).map((element) => element.textContent)
expect(titles).toEqual(['Newest', 'Middle', 'Oldest'])
})
it('hides standalone status badges inside note rows', () => {
renderNoteList()
expect(screen.queryByText('Active')).not.toBeInTheDocument()
})
it('shows search and create actions in the header instead of a count badge', () => {
renderNoteList()
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
})
it('shows backlinks from outgoing links in entity view', () => {
const entriesWithBacklink = mockEntries.map((entry) =>
entry.path === mockEntries[2].path ? { ...entry, outgoingLinks: ['Build Laputa App'] } : entry,
)
renderNoteList({
entries: entriesWithBacklink,
selection: { kind: 'entity', entry: mockEntries[0] },
})
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
})
it('collapses and expands entity groups', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
fireEvent.click(screen.getByText('Children'))
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Children'))
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
})
it('shows the entity snippet in the prominent card', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
})
it('shows the inbox customize-columns action and falls back to type-defined chips', () => {
const entries = [
makeTypeDefinition('Book', ['Priority']),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
properties: { Priority: 'High', Owner: 'Luca' },
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
selection: { kind: 'filter', filter: 'inbox' },
inboxNoteListProperties: null,
onUpdateInboxNoteListProperties: () => undefined,
})
expect(screen.getByTitle('Customize Inbox columns')).toBeInTheDocument()
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
})
it('opens the inbox column picker from the global event and saves new columns', () => {
const onUpdateInboxNoteListProperties = vi.fn()
const entries = [
makeTypeDefinition('Book', ['Priority']),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
properties: { Priority: 'High', Owner: 'Luca' },
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
selection: { kind: 'filter', filter: 'inbox' },
inboxNoteListProperties: null,
onUpdateInboxNoteListProperties,
})
act(() => {
openNoteListPropertiesPicker('inbox')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Priority' })).toBeChecked()
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
})
it('uses inbox overrides when configured', () => {
const entries = [
makeTypeDefinition('Book', ['Priority']),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
properties: { Priority: 'High', Owner: 'Luca' },
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
selection: { kind: 'filter', filter: 'inbox' },
inboxNoteListProperties: ['Owner'],
onUpdateInboxNoteListProperties: () => undefined,
})
expect(screen.getByText('Luca')).toBeInTheDocument()
expect(screen.queryByText('High')).not.toBeInTheDocument()
})
})
describe('NoteList click behavior', () => {
it('opens the current tab on a regular click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'))
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onSelectNote).not.toHaveBeenCalled()
})
it('opens a new tab on Cmd+Click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
})
it('opens a new tab on Ctrl+Click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
})
it('supports Cmd+Click on the entity pinned card', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1], { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
})
it('opens the current tab from the entity pinned card on regular click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1])
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onSelectNote).not.toHaveBeenCalled()
})
it('opens child notes from entity view in the current tab', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[1])
expect(onSelectNote).not.toHaveBeenCalled()
})
})
describe('NoteList type sections', () => {
const typeEntry = {
...makeEntry({
path: '/Users/luca/Laputa/types/project.md',
filename: 'project.md',
title: 'Project',
isA: 'Type',
snippet: 'Defines the Project type.',
modifiedAt: 1700000000,
fileSize: 200,
wordCount: 50,
}),
}
const entriesWithType = [...mockEntries, typeEntry]
it('does not show a type note pinned card while browsing the section', () => {
renderNoteList({
entries: entriesWithType,
selection: { kind: 'sectionGroup', type: 'Project' },
})
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
})
it('renders a clickable type header that opens the type note', () => {
const { onReplaceActiveTab } = renderNoteList({
entries: entriesWithType,
selection: { kind: 'sectionGroup', type: 'Project' },
})
const headerLink = screen.getByTestId('type-header-link')
expect(headerLink).toHaveTextContent('Project')
fireEvent.click(headerLink)
expect(onReplaceActiveTab).toHaveBeenCalledWith(typeEntry)
})
it('does not render a type header outside type sections', () => {
renderNoteList({ entries: entriesWithType, selection: allSelection })
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
})
})
describe('NoteList traffic-light padding', () => {
it('adds left padding when the sidebar is collapsed', () => {
const { container } = renderNoteList({ sidebarCollapsed: true })
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('80px')
})
it('does not add extra left padding when the sidebar is expanded', () => {
const { container } = renderNoteList({ sidebarCollapsed: false })
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
})
it('defaults to no extra padding when sidebarCollapsed is omitted', () => {
const { container } = renderNoteList()
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
})
})

View File

@@ -0,0 +1,295 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, screen } from '@testing-library/react'
import { getSortComparator } from '../utils/noteListHelpers'
import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
describe('getSortComparator', () => {
it('sorts by modified date descending', () => {
const entries = [
makeEntry({ title: 'A', modifiedAt: 1000 }),
makeEntry({ title: 'B', modifiedAt: 3000 }),
makeEntry({ title: 'C', modifiedAt: 2000 }),
]
expect(entries.sort(getSortComparator('modified')).map((entry) => entry.title)).toEqual(['B', 'C', 'A'])
})
it('sorts by created date descending', () => {
const entries = [
makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 }),
makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 }),
makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 }),
]
expect(entries.sort(getSortComparator('created')).map((entry) => entry.title)).toEqual(['A', 'C', 'B'])
})
it('falls back to modifiedAt when createdAt is null', () => {
const entries = [
makeEntry({ title: 'A', createdAt: null, modifiedAt: 5000 }),
makeEntry({ title: 'B', createdAt: 2000, modifiedAt: 1000 }),
]
expect(entries.sort(getSortComparator('created')).map((entry) => entry.title)).toEqual(['A', 'B'])
})
it('sorts by title alphabetically', () => {
const entries = [
makeEntry({ title: 'Zebra' }),
makeEntry({ title: 'Alpha' }),
makeEntry({ title: 'Middle' }),
]
expect(entries.sort(getSortComparator('title')).map((entry) => entry.title)).toEqual(['Alpha', 'Middle', 'Zebra'])
})
it('sorts by status with the expected priority', () => {
const entries = [
makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 }),
makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 }),
makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 }),
makeEntry({ title: 'Paused', status: 'Paused', modifiedAt: 1000 }),
]
expect(entries.sort(getSortComparator('status')).map((entry) => entry.title)).toEqual(['Active', 'Paused', 'Done', 'NoStatus'])
})
it('uses modifiedAt as the status tiebreaker', () => {
const entries = [
makeEntry({ title: 'OlderActive', status: 'Active', modifiedAt: 1000 }),
makeEntry({ title: 'NewerActive', status: 'Active', modifiedAt: 3000 }),
]
expect(entries.sort(getSortComparator('status')).map((entry) => entry.title)).toEqual(['NewerActive', 'OlderActive'])
})
it('supports ascending modified sorting', () => {
const entries = [
makeEntry({ title: 'A', modifiedAt: 1000 }),
makeEntry({ title: 'B', modifiedAt: 3000 }),
makeEntry({ title: 'C', modifiedAt: 2000 }),
]
expect(entries.sort(getSortComparator('modified', 'asc')).map((entry) => entry.title)).toEqual(['A', 'C', 'B'])
})
it('supports descending title sorting', () => {
const entries = [
makeEntry({ title: 'Zebra' }),
makeEntry({ title: 'Alpha' }),
makeEntry({ title: 'Middle' }),
]
expect(entries.sort(getSortComparator('title', 'desc')).map((entry) => entry.title)).toEqual(['Zebra', 'Middle', 'Alpha'])
})
it('supports ascending created sorting', () => {
const entries = [
makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 }),
makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 }),
makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 }),
]
expect(entries.sort(getSortComparator('created', 'asc')).map((entry) => entry.title)).toEqual(['B', 'C', 'A'])
})
it('supports descending status sorting', () => {
const entries = [
makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 }),
makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 }),
makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 }),
]
expect(entries.sort(getSortComparator('status', 'desc')).map((entry) => entry.title)).toEqual(['NoStatus', 'Done', 'Active'])
})
})
describe('NoteList sort controls', () => {
beforeEach(() => {
try {
localStorage.removeItem('laputa-sort-preferences')
} catch {
// ignore storage failures in tests
}
})
const zamEntries = [
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
function openListSortMenu(entries = mockEntries) {
renderNoteList({ entries })
fireEvent.click(screen.getByTestId('sort-button-__list__'))
}
it('shows the sort button in flat list view', () => {
renderNoteList()
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
})
it('shows a per-group sort button in entity view', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
})
it('opens the sort menu and lists all built-in options', () => {
openListSortMenu()
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-created')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-title')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-status')).toBeInTheDocument()
})
it('changes list order when a different sort option is selected', () => {
openListSortMenu(zamEntries)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent)
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
fireEvent.click(screen.getByTestId('sort-option-title'))
titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent)
expect(titles).toEqual(['Alpha', 'Middle', 'Zebra'])
})
it('closes the sort menu after choosing an option', () => {
openListSortMenu()
fireEvent.click(screen.getByTestId('sort-option-title'))
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
})
it('shows direction arrows for every sort option', () => {
openListSortMenu()
expect(screen.getByTestId('sort-dir-asc-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-dir-desc-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-dir-asc-title')).toBeInTheDocument()
expect(screen.getByTestId('sort-dir-desc-title')).toBeInTheDocument()
})
it('reverses list order when a direction arrow is chosen', () => {
openListSortMenu(zamEntries)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent)
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
fireEvent.click(screen.getByTestId('sort-dir-asc-modified'))
titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((element) => element.textContent)
expect(titles).toEqual(['Alpha', 'Middle', 'Zebra'])
})
it('persists the chosen direction', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
]
renderNoteList({ entries })
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-dir-desc-title'))
const titles = screen.getAllByText(/Zebra|Alpha/).map((element) => element.textContent)
expect(titles).toEqual(['Zebra', 'Alpha'])
})
it('keeps the sort direction icon in sync with the active sort', () => {
renderNoteList()
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-title'))
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
})
it('sorts entity relationship groups independently', () => {
const parent = makeEntry({
path: '/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
})
const child1 = makeEntry({
path: '/child1.md',
filename: 'child1.md',
title: 'Zebra Note',
belongsTo: ['[[parent]]'],
modifiedAt: 3000,
})
const child2 = makeEntry({
path: '/child2.md',
filename: 'child2.md',
title: 'Alpha Note',
belongsTo: ['[[parent]]'],
modifiedAt: 1000,
})
renderNoteList({
entries: [parent, child1, child2],
selection: { kind: 'entity', entry: parent },
})
let titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((element) => element.textContent)
expect(titles).toEqual(['Zebra Note', 'Alpha Note'])
fireEvent.click(screen.getByTestId('sort-button-Children'))
fireEvent.click(screen.getByTestId('sort-option-title'))
titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((element) => element.textContent)
expect(titles).toEqual(['Alpha Note', 'Zebra Note'])
})
it('shows custom properties after a separator in the sort menu', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'A', properties: { Priority: 'High', Rating: 5 } }),
makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }),
]
renderNoteList({ entries })
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-separator')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Company')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument()
})
it('omits the custom-property separator when no properties exist', () => {
renderNoteList()
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument()
})
it('sorts entries by a custom property', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Rating: 3 } }),
makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: { Rating: 1 } }),
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }),
]
renderNoteList({ entries })
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-property:Rating'))
const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent)
expect(titles).toEqual(['B', 'A', 'C'])
})
it('pushes entries without a custom property to the end', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Priority: 'High' } }),
makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: {} }),
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }),
]
renderNoteList({ entries })
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-property:Priority'))
const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent)
expect(titles).toEqual(['A', 'C', 'B'])
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,24 @@
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import { countByFilter, countAllByFilter, filterInboxEntries } from '../utils/noteListHelpers'
import { countByFilter, countAllByFilter } 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 { type DeletedNoteEntry, isDeletedNoteEntry, routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
import { type DeletedNoteEntry, resolveHeaderTitle } from './note-list/noteListUtils'
import {
useTypeEntryMap, useNoteListData, useNoteListSearch,
useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState,
useChangeStatusResolver,
useListPropertyPicker,
useModifiedFilesState,
useMultiSelectKeyboard,
useNoteListData,
useNoteListInteractions,
useNoteListSearch,
useNoteListSort,
useTypeEntryMap,
useVisibleNotesSync,
} from './note-list/noteListHooks'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
@@ -31,34 +36,6 @@ function useViewFlags(selection: SidebarSelection) {
return { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills }
}
function collectAvailableProperties(entries: VaultEntry[]): string[] {
const keys = new Set<string>()
for (const entry of entries) {
for (const key of Object.keys(entry.properties ?? {})) keys.add(key)
for (const key of Object.keys(entry.relationships ?? {})) keys.add(key)
}
return [...keys].sort((a, b) => a.localeCompare(b))
}
function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string): string[] {
return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName))
}
function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): string[] {
const ordered: string[] = []
const seen = new Set<string>()
for (const entry of entries) {
for (const key of typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []) {
if (seen.has(key)) continue
seen.add(key)
ordered.push(key)
}
}
return ordered
}
function useBulkActions(
multiSelect: ReturnType<typeof useMultiSelect>,
onBulkArchive: NoteListProps['onBulkArchive'],
@@ -201,151 +178,54 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const typeEntryMap = useTypeEntryMap(entries)
const inboxEntries = useMemo(
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
[entries, inboxPeriod, isInboxView],
)
const typeAvailableProperties = useMemo(
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
[entries, typeDocument],
)
const inboxAvailableProperties = useMemo(
() => collectAvailableProperties(inboxEntries),
[inboxEntries],
)
const inboxDefaultDisplay = useMemo(
() => deriveInboxDefaultDisplay(inboxEntries, typeEntryMap),
[inboxEntries, typeEntryMap],
)
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null
const propertyPicker = useMemo(() => {
if (isInboxView && onUpdateInboxNoteListProperties) {
return {
scope: 'inbox' as const,
availableProperties: inboxAvailableProperties,
currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay,
onSave: onUpdateInboxNoteListProperties,
triggerTitle: 'Customize Inbox columns',
}
}
if (isSectionGroup && typeDocument && onUpdateTypeSort) {
return {
scope: 'type' as const,
availableProperties: typeAvailableProperties,
currentDisplay: typeDocument.listPropertiesDisplay ?? [],
onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value),
triggerTitle: 'Customize columns',
}
}
return null
}, [
hasCustomInboxProperties,
inboxAvailableProperties,
inboxDefaultDisplay,
const { inboxDisplayOverride, propertyPicker } = useListPropertyPicker({
entries,
selection,
inboxPeriod,
typeDocument,
typeEntryMap,
inboxNoteListProperties,
isInboxView,
isSectionGroup,
onUpdateInboxNoteListProperties,
onUpdateTypeSort,
typeAvailableProperties,
typeDocument,
])
const changeStatusMap = useMemo(() => {
if (!isChangesView || !modifiedFiles) return undefined
const map = new Map<string, ModifiedFile['status']>()
for (const mf of modifiedFiles) {
map.set(mf.path, mf.status)
// Also index by suffix for matching (vault entries may use different path formats)
map.set('/' + mf.relativePath, mf.status)
}
return map
}, [isChangesView, modifiedFiles])
})
const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
// Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow)
if (visibleNotesRef) {
visibleNotesRef.current = isEntityView
? searchedGroups.flatMap((g) => g.entries).filter((entry) => !isDeletedNoteEntry(entry))
: searched.filter((entry) => !isDeletedNoteEntry(entry))
}
useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups })
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
const handleKeyboardOpen = useCallback((entry: VaultEntry) => {
if (isDeletedNoteEntry(entry)) {
onOpenDeletedNote?.(entry)
return
}
onReplaceActiveTab(entry)
}, [onOpenDeletedNote, onReplaceActiveTab])
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
}, [])
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: handleKeyboardOpen, onPrefetch: handleKeyboardPrefetch, enabled: !isEntityView })
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
if (isDeletedNoteEntry(entry)) {
routeNoteClick(entry, e, {
onReplace: () => onOpenDeletedNote?.(entry),
onSelect: () => onOpenDeletedNote?.(entry),
multiSelect,
})
return
}
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
if (isChangesView && onAutoTriggerDiff) {
// Small delay to let the tab open before triggering diff
setTimeout(onAutoTriggerDiff, 50)
}
}, [onOpenDeletedNote, onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff])
const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
const {
collapsedGroups,
handleClickNote,
handleCreateNote,
handleListKeyDown,
multiSelect,
noteListKeyboard,
toggleGroup,
} = useNoteListInteractions({
searched,
selectedNotePath: selectedNote?.path ?? null,
selection,
noteListFilter,
isEntityView,
isChangesView,
onReplaceActiveTab,
onSelectNote,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
onDiscardFile,
openContextMenuForEntry,
onCreateNote,
})
const getChangeStatus = useChangeStatusResolver(isChangesView, modifiedFiles)
const { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView)
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrUnarchive, handleBulkDeletePermanently)
const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
const getChangeStatus = useCallback((path: string) => {
if (!changeStatusMap) return undefined
const direct = changeStatusMap.get(path)
if (direct) return direct
// Try suffix match
for (const [key, status] of changeStatusMap) {
if (path.endsWith(key) || key.endsWith(path.split('/').slice(-1)[0])) return status
}
return undefined
}, [changeStatusMap])
const handleListKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (isChangesView && onDiscardFile && e.shiftKey && e.key === 'F10' && noteListKeyboard.highlightedPath) {
const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath)
if (!entry) return
e.preventDefault()
e.stopPropagation()
const row = document.querySelector<HTMLElement>(`[data-note-path="${entry.path}"]`)
const rect = row?.getBoundingClientRect()
openContextMenuForEntry(entry, {
x: rect ? rect.left + 24 : 160,
y: rect ? rect.bottom - 8 : 160,
})
return
}
noteListKeyboard.handleKeyDown(e)
}, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} allEntries={entries} displayPropsOverride={inboxDisplayOverride} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} allEntries={entries} displayPropsOverride={inboxDisplayOverride} onClickNote={handleClickNote} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [entries, selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu, inboxDisplayOverride])
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
}, [onCreateNote, selection])
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
const title = resolveHeaderTitle(selection, typeDocument, views)
return (

View File

@@ -9,8 +9,13 @@ import {
} from '../../utils/noteListHelpers'
import type { InboxPeriod } from '../../types'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
import {
buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver,
isDeletedNoteEntry, isModifiedEntry, routeNoteClick, toggleSetMember,
} from './noteListUtils'
import { useMultiSelect, type MultiSelectState } from '../../hooks/useMultiSelect'
import { useNoteListKeyboard } from '../../hooks/useNoteListKeyboard'
import { prefetchNoteContent } from '../../hooks/useTabManagement'
// --- useTypeEntryMap ---
@@ -220,3 +225,312 @@ export function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined,
)
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
}
// --- useChangeStatusResolver ---
function buildChangeStatusMap(isChangesView: boolean, modifiedFiles?: ModifiedFile[]) {
if (!isChangesView || !modifiedFiles) return undefined
const map = new Map<string, ModifiedFile['status']>()
for (const file of modifiedFiles) {
map.set(file.path, file.status)
map.set('/' + file.relativePath, file.status)
}
return map
}
function resolveChangeStatus(path: string, changeStatusMap?: Map<string, ModifiedFile['status']>) {
if (!changeStatusMap) return undefined
const direct = changeStatusMap.get(path)
if (direct) return direct
const filename = path.split('/').slice(-1)[0]
for (const [key, status] of changeStatusMap) {
if (path.endsWith(key) || key.endsWith(filename)) return status
}
return undefined
}
export function useChangeStatusResolver(isChangesView: boolean, modifiedFiles?: ModifiedFile[]) {
const changeStatusMap = useMemo(
() => buildChangeStatusMap(isChangesView, modifiedFiles),
[isChangesView, modifiedFiles],
)
return useCallback(
(path: string) => resolveChangeStatus(path, changeStatusMap),
[changeStatusMap],
)
}
// --- useVisibleNotesSync ---
interface VisibleNotesSyncParams {
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
isEntityView: boolean
searched: VaultEntry[]
searchedGroups: Array<{ entries: VaultEntry[] }>
}
export function useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups }: VisibleNotesSyncParams) {
useEffect(() => {
if (!visibleNotesRef) return
visibleNotesRef.current = isEntityView
? searchedGroups.flatMap((group) => group.entries).filter((entry) => !isDeletedNoteEntry(entry))
: searched.filter((entry) => !isDeletedNoteEntry(entry))
}, [visibleNotesRef, isEntityView, searched, searchedGroups])
}
// --- useListPropertyPicker ---
function collectAvailableProperties(entries: VaultEntry[]): string[] {
const keys = new Set<string>()
for (const entry of entries) {
for (const key of Object.keys(entry.properties ?? {})) keys.add(key)
for (const key of Object.keys(entry.relationships ?? {})) keys.add(key)
}
return [...keys].sort((a, b) => a.localeCompare(b))
}
function collectTypeAvailableProperties(entries: VaultEntry[], typeName: string): string[] {
return collectAvailableProperties(entries.filter((entry) => entry.isA === typeName))
}
function deriveInboxDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): string[] {
const ordered: string[] = []
const seen = new Set<string>()
for (const entry of entries) {
for (const key of typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []) {
if (seen.has(key)) continue
seen.add(key)
ordered.push(key)
}
}
return ordered
}
export interface NoteListPropertyPicker {
scope: 'inbox' | 'type'
availableProperties: string[]
currentDisplay: string[]
onSave: (value: string[] | null) => void
triggerTitle: string
}
interface UseListPropertyPickerParams {
entries: VaultEntry[]
selection: SidebarSelection
inboxPeriod: InboxPeriod
typeDocument: VaultEntry | null
typeEntryMap: Record<string, VaultEntry>
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
}
export function useListPropertyPicker({
entries,
selection,
inboxPeriod,
typeDocument,
typeEntryMap,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateTypeSort,
}: UseListPropertyPickerParams) {
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isSectionGroup = selection.kind === 'sectionGroup'
const inboxEntries = useMemo(
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
[entries, inboxPeriod, isInboxView],
)
const typeAvailableProperties = useMemo(
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
[entries, typeDocument],
)
const inboxAvailableProperties = useMemo(
() => collectAvailableProperties(inboxEntries),
[inboxEntries],
)
const inboxDefaultDisplay = useMemo(
() => deriveInboxDefaultDisplay(inboxEntries, typeEntryMap),
[inboxEntries, typeEntryMap],
)
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
if (isInboxView && onUpdateInboxNoteListProperties) {
return {
scope: 'inbox',
availableProperties: inboxAvailableProperties,
currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay,
onSave: onUpdateInboxNoteListProperties,
triggerTitle: 'Customize Inbox columns',
}
}
if (isSectionGroup && typeDocument && onUpdateTypeSort) {
return {
scope: 'type',
availableProperties: typeAvailableProperties,
currentDisplay: typeDocument.listPropertiesDisplay ?? [],
onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value),
triggerTitle: 'Customize columns',
}
}
return null
}, [
hasCustomInboxProperties,
inboxAvailableProperties,
inboxDefaultDisplay,
inboxNoteListProperties,
isInboxView,
isSectionGroup,
onUpdateInboxNoteListProperties,
onUpdateTypeSort,
typeAvailableProperties,
typeDocument,
])
return { inboxDisplayOverride, propertyPicker }
}
// --- useNoteListInteractions ---
interface UseNoteListInteractionsParams {
searched: VaultEntry[]
selectedNotePath: string | null
selection: SidebarSelection
noteListFilter: NoteListFilter
isEntityView: boolean
isChangesView: boolean
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
onOpenDeletedNote?: (entry: VaultEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onAutoTriggerDiff?: () => void
onDiscardFile?: (relativePath: string) => Promise<void>
openContextMenuForEntry: (entry: VaultEntry, point: { x: number; y: number }) => void
onCreateNote: (type?: string) => void
}
export function useNoteListInteractions({
searched,
selectedNotePath,
selection,
noteListFilter,
isEntityView,
isChangesView,
onReplaceActiveTab,
onSelectNote,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
onDiscardFile,
openContextMenuForEntry,
onCreateNote,
}: UseNoteListInteractionsParams) {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const handleKeyboardOpen = useCallback((entry: VaultEntry) => {
if (isDeletedNoteEntry(entry)) {
onOpenDeletedNote?.(entry)
return
}
onReplaceActiveTab(entry)
}, [onOpenDeletedNote, onReplaceActiveTab])
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
}, [])
const noteListKeyboard = useNoteListKeyboard({
items: searched,
selectedNotePath,
onOpen: handleKeyboardOpen,
onPrefetch: handleKeyboardPrefetch,
enabled: !isEntityView,
})
const multiSelect = useMultiSelect(searched, selectedNotePath)
useEffect(() => {
multiSelect.clear()
}, [noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes
const handleClickNote = useCallback((entry: VaultEntry, event: React.MouseEvent) => {
if (isDeletedNoteEntry(entry)) {
routeNoteClick(entry, event, {
onReplace: () => onOpenDeletedNote?.(entry),
onSelect: () => onOpenDeletedNote?.(entry),
multiSelect,
})
return
}
routeNoteClick(entry, event, {
onReplace: onReplaceActiveTab,
onSelect: onSelectNote,
onOpenInNewWindow,
multiSelect,
})
if (isChangesView && onAutoTriggerDiff) {
setTimeout(onAutoTriggerDiff, 50)
}
}, [
isChangesView,
multiSelect,
onAutoTriggerDiff,
onOpenDeletedNote,
onOpenInNewWindow,
onReplaceActiveTab,
onSelectNote,
])
const handleListKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (isChangesView && onDiscardFile && event.shiftKey && event.key === 'F10' && noteListKeyboard.highlightedPath) {
const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath)
if (!entry) return
event.preventDefault()
event.stopPropagation()
const row = document.querySelector<HTMLElement>(`[data-note-path="${entry.path}"]`)
const rect = row?.getBoundingClientRect()
openContextMenuForEntry(entry, {
x: rect ? rect.left + 24 : 160,
y: rect ? rect.bottom - 8 : 160,
})
return
}
noteListKeyboard.handleKeyDown(event)
}, [isChangesView, noteListKeyboard, onDiscardFile, openContextMenuForEntry, searched])
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
}, [onCreateNote, selection])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => toggleSetMember(prev, label))
}, [])
return {
collapsedGroups,
handleClickNote,
handleCreateNote,
handleListKeyDown,
multiSelect,
noteListKeyboard,
toggleGroup,
}
}

View File

@@ -0,0 +1,232 @@
import type { ComponentProps } from 'react'
import { render } from '@testing-library/react'
import { vi } from 'vitest'
import { NoteList } from '../components/NoteList'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
type NoteListProps = ComponentProps<typeof NoteList>
export const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
export const mockEntries: VaultEntry[] = [
{
path: '/Users/luca/Laputa/project/26q1-laputa-app.md',
filename: '26q1-laputa-app.md',
title: 'Build Laputa App',
isA: 'Project',
aliases: [],
belongsTo: [],
relatedTo: ['[[topic/software-development]]'],
status: 'Active',
owner: 'Luca',
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
snippet: 'Build a personal knowledge management app.',
wordCount: 0,
relationships: {
'Related to': ['[[topic/software-development]]'],
},
icon: null,
color: null,
order: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
filename: 'facebook-ads-strategy.md',
title: 'Facebook Ads Strategy',
isA: 'Note',
aliases: [],
belongsTo: ['[[project/26q1-laputa-app]]'],
relatedTo: ['[[topic/growth]]'],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 847,
snippet: 'Lookalike audiences convert 3x better.',
wordCount: 0,
relationships: {
'Belongs to': ['[[project/26q1-laputa-app]]'],
'Related to': ['[[topic/growth]]'],
},
icon: null,
color: null,
order: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/person/matteo-cellini.md',
filename: 'matteo-cellini.md',
title: 'Matteo Cellini',
isA: 'Person',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 320,
snippet: 'Sponsorship manager.',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
filename: '2026-02-14-kickoff.md',
title: 'Kickoff Meeting',
isA: 'Event',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 512,
snippet: 'Project kickoff meeting notes.',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/topic/software-development.md',
filename: 'software-development.md',
title: 'Software Development',
isA: 'Topic',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Frontend, backend, and systems programming.',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
},
]
export const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/test.md',
filename: 'test.md',
title: 'Test',
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
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,
})
export const makeIndexedEntry = (index: number, overrides?: Partial<VaultEntry>): VaultEntry =>
makeEntry({
path: `/vault/note/note-${index}.md`,
filename: `note-${index}.md`,
title: `Note ${index}`,
isA: 'Note',
modifiedAt: 1700000000 - index * 60,
fileSize: 500,
snippet: `Content of note ${index}`,
...overrides,
})
export const makeTypeDefinition = (title: string, displayProps: string[] = []): VaultEntry =>
makeEntry({
path: `/vault/type/${title.toLowerCase()}.md`,
filename: `${title.toLowerCase()}.md`,
title,
isA: 'Type',
listPropertiesDisplay: displayProps,
})
export function createNoteListSpies() {
return {
onSelectNote: vi.fn(),
onReplaceActiveTab: vi.fn(),
onCreateNote: vi.fn(),
onNoteListFilterChange: vi.fn(),
}
}
export function buildNoteListProps(overrides: Partial<NoteListProps> = {}) {
const spies = createNoteListSpies()
const props: NoteListProps = {
entries: mockEntries,
selection: allSelection,
selectedNote: null,
noteListFilter: 'open' as NoteListFilter,
onNoteListFilterChange: spies.onNoteListFilterChange,
onSelectNote: spies.onSelectNote,
onReplaceActiveTab: spies.onReplaceActiveTab,
onCreateNote: spies.onCreateNote,
...overrides,
}
return { props, ...spies }
}
export function renderNoteList(overrides: Partial<NoteListProps> = {}) {
const built = buildNoteListProps(overrides)
return {
...render(<NoteList {...built.props} />),
...built,
}
}

View File

@@ -1,739 +1,104 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, isInboxEntry, filterInboxEntries, filterEntries } from './noteListHelpers'
import type { VaultEntry } from '../types'
import { describe, expect, it } from 'vitest'
import { countAllByFilter, countByFilter, filterEntries } from './noteListHelpers'
import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/note/test.md', filename: 'test.md', title: 'Test',
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [],
status: null, organized: false, archived: false,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
...overrides,
}
}
describe('formatSubtitle', () => {
afterEach(() => { vi.restoreAllMocks() })
it('shows date and word count when both available', () => {
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 342 })
const result = formatSubtitle(entry)
expect(result).toContain('342 words')
expect(result).toContain('\u00b7')
describe('filterEntries', () => {
it('returns empty for entity selections because entity view uses grouped relationships', () => {
const result = filterEntries(mockEntries, { kind: 'entity', entry: mockEntries[4] })
expect(result).toHaveLength(0)
})
it('shows "Empty" when word count is 0', () => {
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 })
const result = formatSubtitle(entry)
expect(result).toContain('Empty')
expect(result).not.toContain('words')
})
it('shows only word count when no date available', () => {
const entry = makeEntry({ wordCount: 100 })
expect(formatSubtitle(entry)).toBe('100 words')
})
it('shows only "Empty" when no date and no content', () => {
const entry = makeEntry()
expect(formatSubtitle(entry)).toBe('Empty')
})
it('falls back to createdAt when modifiedAt is null', () => {
const entry = makeEntry({ createdAt: 1700000000, wordCount: 50 })
const result = formatSubtitle(entry)
expect(result).toContain('50 words')
expect(result).toContain('\u00b7')
})
it('includes link count when outgoingLinks is non-empty', () => {
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 200, outgoingLinks: ['a', 'b', 'c'] })
const result = formatSubtitle(entry)
expect(result).toContain('3 links')
})
it('uses singular "link" when exactly one', () => {
const entry = makeEntry({ wordCount: 100, outgoingLinks: ['one'] })
expect(formatSubtitle(entry)).toContain('1 link')
expect(formatSubtitle(entry)).not.toContain('1 links')
})
it('omits link count when outgoingLinks is empty', () => {
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] })
expect(formatSubtitle(entry)).not.toContain('link')
})
it('formats word count with locale separators for large numbers', () => {
const entry = makeEntry({ wordCount: 1240 })
const result = formatSubtitle(entry)
expect(result).toMatch(/1,?240 words/)
})
})
describe('formatSearchSubtitle', () => {
afterEach(() => { vi.restoreAllMocks() })
it('shows modified date, created date, word count, and links', () => {
const now = Math.floor(Date.now() / 1000)
const entry = makeEntry({
modifiedAt: now - 3600,
createdAt: now - 86400 * 30,
wordCount: 520,
outgoingLinks: ['a', 'b', 'c', 'd', 'e'],
})
const result = formatSearchSubtitle(entry)
expect(result).toContain('1h ago')
expect(result).toContain('Created')
expect(result).toContain('520 words')
expect(result).toContain('5 links')
})
it('omits created date when same as modified', () => {
const now = Math.floor(Date.now() / 1000)
const entry = makeEntry({ modifiedAt: now, createdAt: now, wordCount: 100 })
const result = formatSearchSubtitle(entry)
expect(result).not.toContain('Created')
})
it('omits created date when createdAt is null', () => {
const now = Math.floor(Date.now() / 1000)
const entry = makeEntry({ modifiedAt: now, createdAt: null, wordCount: 100 })
const result = formatSearchSubtitle(entry)
expect(result).not.toContain('Created')
})
it('shows "Empty" for zero word count', () => {
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 0 })
expect(formatSearchSubtitle(entry)).toContain('Empty')
})
it('omits link count when no outgoing links', () => {
const entry = makeEntry({ modifiedAt: 1700000000, wordCount: 50, outgoingLinks: [] })
expect(formatSearchSubtitle(entry)).not.toContain('link')
})
it('falls back to createdAt when modifiedAt is null', () => {
const entry = makeEntry({ createdAt: 1700000000, wordCount: 200, outgoingLinks: ['a'] })
const result = formatSearchSubtitle(entry)
expect(result).toContain('200 words')
expect(result).toContain('1 link')
expect(result).not.toContain('Created')
})
})
describe('relativeDate', () => {
it('returns empty string for null', () => {
expect(relativeDate(null)).toBe('')
})
it('returns "just now" for recent timestamps', () => {
const now = Math.floor(Date.now() / 1000)
expect(relativeDate(now)).toBe('just now')
})
it('returns minutes ago for timestamps within an hour', () => {
const fiveMinAgo = Math.floor(Date.now() / 1000) - 300
expect(relativeDate(fiveMinAgo)).toBe('5m ago')
})
it('returns hours ago for timestamps within a day', () => {
const twoHoursAgo = Math.floor(Date.now() / 1000) - 7200
expect(relativeDate(twoHoursAgo)).toBe('2h ago')
})
it('returns days ago for timestamps within a week', () => {
const threeDaysAgo = Math.floor(Date.now() / 1000) - 86400 * 3
expect(relativeDate(threeDaysAgo)).toBe('3d ago')
})
it('returns formatted date for older timestamps', () => {
// Use a fixed timestamp: Nov 14, 2023
expect(relativeDate(1700000000)).toMatch(/Nov 14/)
})
})
// --- buildRelationshipGroups tests ---
function makeVault(overrides: Partial<VaultEntry>[]): VaultEntry[] {
return overrides.map((o, i) => makeEntry({
path: `/Laputa/note/entry-${i}.md`,
filename: `entry-${i}.md`,
title: `Entry ${i}`,
modifiedAt: 1700000000 - i * 100,
...o,
}))
}
describe('buildRelationshipGroups', () => {
it('shows direct relationship properties from entity.relationships', () => {
const building = makeEntry({ path: '/Laputa/responsibility/building.md', filename: 'building.md', title: 'Building' })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { 'Belongs to': ['[[responsibility/building]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, building])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Belongs to')
expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building')
})
it('shows all direct relationships even when entries also appear as Children', () => {
// The entity has "Notes" pointing at note1 and note2.
// Those notes also have belongsTo pointing back at the entity.
// Previously, Children consumed them via the seen set, suppressing "Notes".
const note1 = makeEntry({ path: '/Laputa/note/note1.md', filename: 'note1.md', title: 'Note 1', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const note2 = makeEntry({ path: '/Laputa/note/note2.md', filename: 'note2.md', title: 'Note 2', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, note1, note2])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Notes')
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
})
it('shows all 5+ direct relationship properties', () => {
const entries = makeVault([
{ path: '/Laputa/area/eng.md', filename: 'eng.md', title: 'Engineering' },
{ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' },
{ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' },
{ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' },
{ path: '/Laputa/topic/rust.md', filename: 'rust.md', title: 'Rust' },
{ path: '/Laputa/project/sibling.md', filename: 'sibling.md', title: 'Sibling' },
])
const entity = makeEntry({
path: '/Laputa/project/big.md', filename: 'big.md', title: 'Big Project',
relationships: {
'Belongs to': ['[[area/eng]]'],
Notes: ['[[note/n1]]', '[[note/n2]]'],
Owner: ['[[person/alice]]'],
'Related to': ['[[project/sibling]]'],
Topics: ['[[topic/rust]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, ...entries])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Belongs to')
expect(labels).toContain('Notes')
expect(labels).toContain('Owner')
expect(labels).toContain('Related to')
expect(labels).toContain('Topics')
})
it('shows Children group for reverse belongsTo entries not covered by direct rels', () => {
const child = makeEntry({ path: '/Laputa/note/child.md', filename: 'child.md', title: 'Child', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, child])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Children')
expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child')
})
it('excludes Type key from relationship groups', () => {
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { Type: ['[[project]]'] },
})
const groups = buildRelationshipGroups(entity, [entity])
const labels = groups.map((g) => g.label)
expect(labels).not.toContain('Type')
})
it('returns empty groups for entity with no relationships', () => {
const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} })
const groups = buildRelationshipGroups(entity, [entity])
expect(groups).toHaveLength(0)
})
it('shows single-item and multi-item relationship properties', () => {
const alice = makeEntry({ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' })
const n1 = makeEntry({ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' })
const n2 = makeEntry({ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' })
const entity = makeEntry({
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
relationships: {
Owner: ['[[person/alice]]'],
Notes: ['[[note/n1]]', '[[note/n2]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2])
expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1)
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
})
it('shows Instances group for Type entities', () => {
const instance1 = makeEntry({ path: '/Laputa/project/a.md', filename: 'a.md', title: 'Project A', isA: 'Project', modifiedAt: 1700000000 })
const instance2 = makeEntry({ path: '/Laputa/project/b.md', filename: 'b.md', title: 'Project B', isA: 'Project', modifiedAt: 1700000000 })
const typeEntity = makeEntry({
path: '/Laputa/project.md', filename: 'project.md', title: 'Project',
isA: 'Type', relationships: {},
})
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Instances')
expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2)
})
it('direct relationships are sorted alphabetically', () => {
const a = makeEntry({ path: '/Laputa/note/a.md', filename: 'a.md', title: 'A' })
const b = makeEntry({ path: '/Laputa/note/b.md', filename: 'b.md', title: 'B' })
const c = makeEntry({ path: '/Laputa/note/c.md', filename: 'c.md', title: 'C' })
const entity = makeEntry({
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
relationships: {
Zebra: ['[[note/c]]'],
Alpha: ['[[note/a]]'],
Middle: ['[[note/b]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, a, b, c])
const directLabels = groups.map((g) => g.label)
expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle'))
expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra'))
})
it('Referenced By shows entries whose relatedTo matches the entity', () => {
const referer = makeEntry({
path: '/Laputa/project/ref.md', filename: 'ref.md', title: 'Referer',
relatedTo: ['[[project/alpha]]'], modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, referer])
expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer')
})
it('Backlinks shows entries that mention the entity via outgoingLinks', () => {
const linker = makeEntry({
path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000,
outgoingLinks: ['Alpha'],
})
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, linker])
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
})
it('resolves all entries in a large Notes relationship (regression: No Code)', () => {
// Simulates the No Code topic note with 32 Notes, 2 Referred by Data, 1 Belongs to
const noteRefs = [
'8020', 'airdev-build-hub', 'airdev-leader', 'budibase', 'bullet-launch',
'canvas', 'chameleon', 'felt', 'flutterflow', 'framer-ai',
'jumpstart', 'mailparser', 'make', 'michele-sampieri', 'n8n-a',
'n8n-ai', 'nocodey', 'outseta', 'lemon-squeezy', 'retool',
'rise-no-code', 'scene', 'scrapingbee', 'softr', 'superblocks',
'superwall', 'tails', 'supabase', 'varun-anand', 'xano',
'directus', 'framer-design',
]
const noteEntries = noteRefs.map((slug, i) => makeEntry({
path: `/Laputa/${slug}.md`, filename: `${slug}.md`, title: `Title ${slug}`,
modifiedAt: 1700000000 - i * 100,
}))
const engineering = makeEntry({
path: '/Laputa/engineering.md', filename: 'engineering.md', title: 'Engineering',
modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/Laputa/no-code.md', filename: 'no-code.md', title: 'No Code',
isA: 'Topic',
relationships: {
'Belongs to': ['[[engineering|Engineering]]'],
Notes: noteRefs.map((slug) => `[[${slug}|Title ${slug}]]`),
'Referred by Data': ['[[michele-sampieri|Michele Sampieri]]', '[[varun-anand|Varun Anand]]'],
},
})
const allEntries = [entity, engineering, ...noteEntries]
const groups = buildRelationshipGroups(entity, allEntries)
const belongsGroup = groups.find((g) => g.label === 'Belongs to')
expect(belongsGroup).toBeDefined()
expect(belongsGroup!.entries).toHaveLength(1)
const notesGroup = groups.find((g) => g.label === 'Notes')
expect(notesGroup).toBeDefined()
expect(notesGroup!.entries).toHaveLength(32)
// michele-sampieri and varun-anand already consumed by Notes → Referred by Data has 0 new
const referredGroup = groups.find((g) => g.label === 'Referred by Data')
expect(referredGroup).toBeUndefined()
})
it('resolves refs by title when filename differs from wikilink target', () => {
// Wikilink [[Airdev]] but filename is airdev-tool.md, title is "Airdev"
const airdev = makeEntry({
path: '/vault/airdev-tool.md', filename: 'airdev-tool.md', title: 'Airdev',
})
const budibase = makeEntry({
path: '/vault/budibase-app.md', filename: 'budibase-app.md', title: 'Budibase',
aliases: ['Budi'],
})
const entity = makeEntry({
path: '/vault/no-code.md', filename: 'no-code.md', title: 'No Code',
relationships: { Notes: ['[[Airdev]]', '[[Budi]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, airdev, budibase])
const notesGroup = groups.find((g) => g.label === 'Notes')
expect(notesGroup).toBeDefined()
expect(notesGroup!.entries).toHaveLength(2)
expect(notesGroup!.entries.map(e => e.title).sort()).toEqual(['Airdev', 'Budibase'])
})
it('resolves Children via title match when belongsTo target differs from filename', () => {
// Child's belongsTo uses [[No Code]] but entity filename is no-code-topic.md
const child = makeEntry({
path: '/vault/tool.md', filename: 'tool.md', title: 'Tool',
belongsTo: ['[[No Code]]'], modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/vault/no-code-topic.md', filename: 'no-code-topic.md', title: 'No Code',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, child])
expect(groups.find((g) => g.label === 'Children')!.entries).toHaveLength(1)
})
})
describe('getSortComparator — custom properties', () => {
it('sorts by string property alphabetically', () => {
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
const b = makeEntry({ title: 'B', properties: { Priority: 'Low' } })
const c = makeEntry({ title: 'C', properties: { Priority: 'Medium' } })
const sorted = [a, b, c].sort(getSortComparator('property:Priority'))
expect(sorted.map((e) => e.title)).toEqual(['A', 'B', 'C'])
})
it('sorts by numeric property', () => {
const a = makeEntry({ title: 'A', properties: { Rating: 3 } })
const b = makeEntry({ title: 'B', properties: { Rating: 5 } })
const c = makeEntry({ title: 'C', properties: { Rating: 1 } })
const sorted = [a, b, c].sort(getSortComparator('property:Rating'))
expect(sorted.map((e) => e.title)).toEqual(['C', 'A', 'B'])
})
it('sorts by date property chronologically', () => {
const a = makeEntry({ title: 'A', properties: { 'Due date': '2026-06-15' } })
const b = makeEntry({ title: 'B', properties: { 'Due date': '2026-01-01' } })
const c = makeEntry({ title: 'C', properties: { 'Due date': '2026-03-10' } })
const sorted = [a, b, c].sort(getSortComparator('property:Due date'))
expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A'])
})
it('pushes null values to end regardless of direction', () => {
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
const b = makeEntry({ title: 'B', properties: {} })
const c = makeEntry({ title: 'C', properties: { Priority: 'Low' } })
const ascSorted = [a, b, c].sort(getSortComparator('property:Priority', 'asc'))
expect(ascSorted.map((e) => e.title)).toEqual(['A', 'C', 'B'])
const descSorted = [a, b, c].sort(getSortComparator('property:Priority', 'desc'))
expect(descSorted.map((e) => e.title)).toEqual(['C', 'A', 'B'])
})
it('sorts descending when direction is desc', () => {
const a = makeEntry({ title: 'A', properties: { Rating: 3 } })
const b = makeEntry({ title: 'B', properties: { Rating: 5 } })
const c = makeEntry({ title: 'C', properties: { Rating: 1 } })
const sorted = [a, b, c].sort(getSortComparator('property:Rating', 'desc'))
expect(sorted.map((e) => e.title)).toEqual(['B', 'A', 'C'])
})
it('handles entries with no properties field gracefully', () => {
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
const b = makeEntry({ title: 'B', properties: {} })
const sorted = [a, b].sort(getSortComparator('property:Priority'))
expect(sorted.map((e) => e.title)).toEqual(['A', 'B'])
})
it('handles boolean property sorting', () => {
const a = makeEntry({ title: 'A', properties: { Reviewed: true } })
const b = makeEntry({ title: 'B', properties: { Reviewed: false } })
const sorted = [a, b].sort(getSortComparator('property:Reviewed'))
expect(sorted.map((e) => e.title)).toEqual(['B', 'A'])
})
})
describe('extractSortableProperties', () => {
it('returns union of all property keys across entries', () => {
it('filters section groups by open sub-filter', () => {
const entries = [
makeEntry({ properties: { Priority: 'High', Rating: 5 } }),
makeEntry({ properties: { Priority: 'Low', Company: 'Acme' } }),
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
]
expect(extractSortableProperties(entries)).toEqual(['Company', 'Priority', 'Rating'])
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'open')
expect(result.map((entry) => entry.title)).toEqual(['Active'])
})
it('returns empty array for entries without properties', () => {
const entries = [makeEntry(), makeEntry()]
expect(extractSortableProperties(entries)).toEqual([])
})
it('returns empty array for empty entry list', () => {
expect(extractSortableProperties([])).toEqual([])
})
it('deduplicates property keys', () => {
it('filters section groups by archived sub-filter', () => {
const entries = [
makeEntry({ properties: { Priority: 'High' } }),
makeEntry({ properties: { Priority: 'Low' } }),
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
]
expect(extractSortableProperties(entries)).toEqual(['Priority'])
})
})
describe('getSortOptionLabel', () => {
it('returns label for built-in options', () => {
expect(getSortOptionLabel('modified')).toBe('Modified')
expect(getSortOptionLabel('title')).toBe('Title')
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'archived')
expect(result.map((entry) => entry.title)).toEqual(['Archived'])
})
it('returns property key for custom properties', () => {
expect(getSortOptionLabel('property:Priority')).toBe('Priority')
expect(getSortOptionLabel('property:Due date')).toBe('Due date')
})
})
describe('getDefaultDirection', () => {
it('returns desc for time-based sorts', () => {
expect(getDefaultDirection('modified')).toBe('desc')
expect(getDefaultDirection('created')).toBe('desc')
})
it('returns asc for other sorts', () => {
expect(getDefaultDirection('title')).toBe('asc')
expect(getDefaultDirection('status')).toBe('asc')
expect(getDefaultDirection('property:Priority')).toBe('asc')
})
})
describe('serializeSortConfig', () => {
it('serializes a built-in sort config', () => {
expect(serializeSortConfig({ option: 'modified', direction: 'desc' })).toBe('modified:desc')
expect(serializeSortConfig({ option: 'title', direction: 'asc' })).toBe('title:asc')
})
it('serializes a custom property sort config', () => {
expect(serializeSortConfig({ option: 'property:Priority', direction: 'asc' })).toBe('property:Priority:asc')
})
})
describe('parseSortConfig', () => {
it('parses a built-in sort config', () => {
expect(parseSortConfig('modified:desc')).toEqual({ option: 'modified', direction: 'desc' })
expect(parseSortConfig('title:asc')).toEqual({ option: 'title', direction: 'asc' })
})
it('parses a custom property sort config with colon in option', () => {
expect(parseSortConfig('property:Priority:asc')).toEqual({ option: 'property:Priority', direction: 'asc' })
})
it('returns null for null/undefined input', () => {
expect(parseSortConfig(null)).toBeNull()
expect(parseSortConfig(undefined)).toBeNull()
})
it('returns null for empty string', () => {
expect(parseSortConfig('')).toBeNull()
})
it('returns null for invalid direction', () => {
expect(parseSortConfig('modified:up')).toBeNull()
})
it('returns null for string without colon', () => {
expect(parseSortConfig('modified')).toBeNull()
})
it('roundtrips correctly', () => {
const configs = [
{ option: 'modified' as const, direction: 'desc' as const },
{ option: 'title' as const, direction: 'asc' as const },
{ option: 'property:Due date' as const, direction: 'desc' as const },
]
for (const config of configs) {
expect(parseSortConfig(serializeSortConfig(config))).toEqual(config)
}
})
})
// --- Inbox ---
describe('isInboxEntry', () => {
it('returns true for a note that is not organized', () => {
const note = makeEntry({ organized: false })
expect(isInboxEntry(note)).toBe(true)
})
it('returns false for an organized note', () => {
const note = makeEntry({ organized: true })
expect(isInboxEntry(note)).toBe(false)
})
it('returns false for an archived note', () => {
const note = makeEntry({ archived: true })
expect(isInboxEntry(note)).toBe(false)
})
it('excludes Type entries from inbox', () => {
const note = makeEntry({ isA: 'Type' })
expect(isInboxEntry(note)).toBe(false)
})
it('returns true for a note without _organized field (defaults to false)', () => {
const note = makeEntry({})
expect(isInboxEntry(note)).toBe(true)
})
})
describe('filterInboxEntries', () => {
const now = Math.floor(Date.now() / 1000)
const DAY = 86400
const allEntries = [
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'A', createdAt: now - 2 * DAY }),
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'B', createdAt: now - 15 * DAY }),
makeEntry({ path: '/vault/c.md', filename: 'c.md', title: 'C', createdAt: now - 60 * DAY }),
makeEntry({ path: '/vault/d.md', filename: 'd.md', title: 'D', createdAt: now - 120 * DAY }),
makeEntry({ path: '/vault/org.md', filename: 'org.md', title: 'Organized', createdAt: now - 1 * DAY, organized: true }),
]
it('filters by "week" period (last 7 days)', () => {
const result = filterInboxEntries(allEntries, 'week')
expect(result.map(e => e.title)).toEqual(['A'])
})
it('filters by "month" period (last 30 days)', () => {
const result = filterInboxEntries(allEntries, 'month')
expect(result.map(e => e.title)).toEqual(['A', 'B'])
})
it('filters by "quarter" period (last 90 days)', () => {
const result = filterInboxEntries(allEntries, 'quarter')
expect(result.map(e => e.title)).toEqual(['A', 'B', 'C'])
})
it('filters by "all" period', () => {
const result = filterInboxEntries(allEntries, 'all')
expect(result.map(e => e.title)).toEqual(['A', 'B', 'C', 'D'])
})
it('sorts by createdAt descending', () => {
const result = filterInboxEntries(allEntries, 'all')
for (let i = 1; i < result.length; i++) {
expect((result[i - 1].createdAt ?? 0)).toBeGreaterThanOrEqual((result[i].createdAt ?? 0))
}
})
it('excludes organized notes', () => {
const result = filterInboxEntries(allEntries, 'all')
expect(result.find(e => e.title === 'Organized')).toBeUndefined()
})
it('returns empty array when all notes are organized', () => {
const organized = [
makeEntry({ path: '/vault/x.md', title: 'X', organized: true }),
makeEntry({ path: '/vault/y.md', title: 'Y', organized: true }),
]
const result = filterInboxEntries(organized, 'all')
expect(result).toEqual([])
})
})
describe('filterEntries — folder selection', () => {
const entries = [
makeEntry({ path: '/vault/projects/laputa/note1.md', title: 'Note 1' }),
makeEntry({ path: '/vault/projects/laputa/note2.md', title: 'Note 2' }),
makeEntry({ path: '/vault/projects/portfolio/site.md', title: 'Site' }),
makeEntry({ path: '/vault/areas/health.md', title: 'Health' }),
makeEntry({ path: '/vault/root-note.md', title: 'Root' }),
]
it('filters entries by folder path', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' })
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2'])
})
it('does not include notes from sibling folders', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' })
expect(result.find(e => e.title === 'Site')).toBeUndefined()
})
it('filters recursively — includes notes from subfolders', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'projects' })
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2', 'Site'])
})
it('filters direct children', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'areas' })
expect(result.map(e => e.title)).toEqual(['Health'])
})
it('returns empty for root when entries are in subfolders', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'nonexistent' })
expect(result).toEqual([])
})
it('excludes archived entries by default', () => {
const withArchived = [
...entries,
makeEntry({ path: '/vault/projects/laputa/archived.md', title: 'Archived', archived: true }),
]
const result = filterEntries(withArchived, { kind: 'folder', path: 'projects/laputa' })
expect(result.find(e => e.title === 'Archived')).toBeUndefined()
})
})
describe('filterEntries — fileKind filtering', () => {
const entries = [
makeEntry({ path: '/vault/note.md', title: 'Note', fileKind: 'markdown' }),
makeEntry({ path: '/vault/config.yml', title: 'config.yml', fileKind: 'text' }),
makeEntry({ path: '/vault/photo.png', title: 'photo.png', fileKind: 'binary' }),
makeEntry({ path: '/vault/projects/readme.md', title: 'README', fileKind: 'markdown' }),
makeEntry({ path: '/vault/projects/data.json', title: 'data.json', fileKind: 'text' }),
makeEntry({ path: '/vault/projects/image.jpg', title: 'image.jpg', fileKind: 'binary' }),
]
it('all-notes filter only shows markdown files', () => {
const result = filterEntries(entries, { kind: 'filter', filter: 'all' })
expect(result.map(e => e.title)).toEqual(['Note', 'README'])
})
it('folder view shows all file kinds including binary', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'projects' })
expect(result.map(e => e.title)).toEqual(['README', 'data.json', 'image.jpg'])
})
it('sectionGroup filter only shows markdown files', () => {
const typed = entries.map(e => ({ ...e, isA: 'Note' }))
const result = filterEntries(typed, { kind: 'sectionGroup', type: 'Note' })
expect(result.map(e => e.title)).toEqual(['Note', 'README'])
})
it('entries without fileKind are treated as markdown', () => {
const legacy = [
makeEntry({ path: '/vault/old.md', title: 'Old' }),
]
const result = filterEntries(legacy, { kind: 'filter', filter: 'all' })
expect(result.map(e => e.title)).toEqual(['Old'])
})
})
describe('filterInboxEntries — excludes non-markdown files', () => {
it('only shows markdown files in inbox', () => {
const now = Math.floor(Date.now() / 1000)
it('defaults section groups to active notes when no sub-filter is provided', () => {
const entries = [
makeEntry({ path: '/vault/note.md', title: 'Note', fileKind: 'markdown', createdAt: now }),
makeEntry({ path: '/vault/config.yml', title: 'config.yml', fileKind: 'text', createdAt: now }),
makeEntry({ path: '/vault/photo.png', title: 'photo.png', fileKind: 'binary', createdAt: now }),
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
]
const result = filterInboxEntries(entries, 'all')
expect(result.map(e => e.title)).toEqual(['Note'])
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
expect(result.map((entry) => entry.title)).toEqual(['Active'])
})
it('filters all notes by open sub-filter', () => {
const entries = [
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
]
const result = filterEntries(entries, allSelection, 'open')
expect(result.map((entry) => entry.title)).toEqual(['Active', 'Other'])
})
it('filters all notes by archived sub-filter', () => {
const entries = [
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
]
const result = filterEntries(entries, allSelection, 'archived')
expect(result.map((entry) => entry.title)).toEqual(['Archived'])
})
})
describe('countByFilter', () => {
it('counts open and archived entries for a type', () => {
const entries = [
makeEntry({ path: '/1.md', isA: 'Project' }),
makeEntry({ path: '/2.md', isA: 'Project', archived: true }),
makeEntry({ path: '/3.md', isA: 'Project' }),
makeEntry({ path: '/4.md', isA: 'Note' }),
]
expect(countByFilter(entries, 'Project')).toEqual({ open: 2, archived: 1 })
})
it('returns zeros when a type has no matching entries', () => {
expect(countByFilter([], 'Project')).toEqual({ open: 0, archived: 0 })
})
})
describe('countAllByFilter', () => {
it('counts all entries by archive status', () => {
const entries = [
makeEntry({ path: '/1.md', isA: 'Project' }),
makeEntry({ path: '/2.md', isA: 'Note' }),
makeEntry({ path: '/3.md', isA: 'Project', archived: true }),
]
expect(countAllByFilter(entries)).toEqual({ open: 2, archived: 1 })
})
it('excludes non-markdown files from counts', () => {
const entries = [
makeEntry({ path: '/1.md', isA: 'Note', fileKind: 'markdown' }),
makeEntry({ path: '/2.yml', isA: undefined, fileKind: 'text' }),
makeEntry({ path: '/3.png', isA: undefined, fileKind: 'binary' }),
]
expect(countAllByFilter(entries)).toEqual({ open: 1, archived: 0 })
})
})