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,
}
}