+ <>
onReplaceActiveTab(typeDocument) : undefined}
+ onClick={typeDocument ? () => onOpenType(typeDocument) : undefined}
data-testid={typeDocument ? 'type-header-link' : undefined}
>
- {resolveHeaderTitle(selection, typeDocument)}
+ {title}
- {!isEntityView && }
-
-
{searchVisible && (
- setSearch(e.target.value)} className="h-8 text-[13px]" autoFocus />
+ onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus />
)}
+ >
+ )
+}
+// --- Main component ---
+
+const defaultGetNoteStatus = (): NoteStatus => 'clean'
+
+function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNoteStatus: ((path: string) => NoteStatus) | undefined) {
+ const modifiedPathSet = useMemo(() => new Set((modifiedFiles ?? []).map((f) => f.path)), [modifiedFiles])
+ const modifiedSuffixes = useMemo(() => (modifiedFiles ?? []).map((f) => '/' + f.relativePath), [modifiedFiles])
+ const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(
+ () => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet),
+ [getNoteStatus, modifiedFiles, modifiedPathSet],
+ )
+ return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
+}
+
+function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
+ const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
+ const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
+ const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
+ const [collapsedGroups, setCollapsedGroups] = useState
>(new Set())
+
+ const typeEntryMap = useTypeEntryMap(entries)
+ const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
+ const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
+ const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
+
+ const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
+ const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
+ useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
+
+ const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
+ routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, multiSelect })
+ }, [onReplaceActiveTab, onSelectNote, multiSelect])
+
+ const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
+ const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
+ useMultiSelectKeyboard(multiSelect, isEntityView, handleBulkArchive, handleBulkTrash)
+
+ const renderItem = useCallback((entry: VaultEntry) => (
+
+ ), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
+
+ const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
+ const title = resolveHeaderTitle(selection, typeDocument)
+
+ return (
+
+
- {isEntityView && selection.kind === 'entity' ? (
-
+ {entitySelection ? (
+
) : (
)}
-
{multiSelect.isMultiSelecting && (
)}
diff --git a/src/components/useNoteListSort.test.tsx b/src/components/useNoteListSort.test.tsx
new file mode 100644
index 00000000..60df1702
--- /dev/null
+++ b/src/components/useNoteListSort.test.tsx
@@ -0,0 +1,151 @@
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NoteList } from './NoteList'
+import type { VaultEntry, SidebarSelection } from '../types'
+
+const localStorageMock = (() => {
+ let store: Record
= {}
+ return {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => { store[key] = value }),
+ removeItem: vi.fn((key: string) => { delete store[key] }),
+ clear: vi.fn(() => { store = {} }),
+ }
+})()
+Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
+
+function makeEntry(overrides: Partial = {}): VaultEntry {
+ return {
+ path: '/test/note.md', filename: 'note.md', title: 'Test Note',
+ isA: 'Note', aliases: [], belongsTo: [], relatedTo: [],
+ status: null, owner: null, cadence: null, archived: false,
+ trashed: false, trashedAt: null, modifiedAt: 1700000000,
+ createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
+ relationships: {}, icon: null, color: null, order: null,
+ sidebarLabel: null, template: null, sort: null,
+ outgoingLinks: [], properties: {},
+ ...overrides,
+ }
+}
+
+const noop = vi.fn()
+
+function renderNoteList(props: {
+ entries: VaultEntry[]
+ selection: SidebarSelection
+ onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
+ updateEntry?: (path: string, patch: Partial) => void
+}) {
+ return render(
+ ,
+ )
+}
+
+beforeEach(() => { localStorageMock.clear() })
+
+describe('useNoteListSort (via NoteList)', () => {
+ it('renders notes sorted by modified date by default', () => {
+ const entries = [
+ makeEntry({ path: '/a.md', title: 'Alpha', modifiedAt: 1000 }),
+ makeEntry({ path: '/b.md', title: 'Beta', modifiedAt: 3000 }),
+ makeEntry({ path: '/c.md', title: 'Charlie', modifiedAt: 2000 }),
+ ]
+ renderNoteList({ entries, selection: { kind: 'filter', filter: 'all' } })
+ const items = screen.getAllByText(/Alpha|Beta|Charlie/)
+ expect(items[0].textContent).toBe('Beta')
+ expect(items[1].textContent).toBe('Charlie')
+ expect(items[2].textContent).toBe('Alpha')
+ })
+
+ it('reads sort from type document for sectionGroup selection', () => {
+ const typeDoc = makeEntry({ path: '/type/note.md', title: 'Note', isA: 'Type', sort: 'title:asc' })
+ const entries = [
+ typeDoc,
+ makeEntry({ path: '/c.md', title: 'Charlie', modifiedAt: 3000 }),
+ makeEntry({ path: '/a.md', title: 'Alpha', modifiedAt: 1000 }),
+ makeEntry({ path: '/b.md', title: 'Beta', modifiedAt: 2000 }),
+ ]
+ renderNoteList({ entries, selection: { kind: 'sectionGroup', type: 'Note', label: 'Notes' } })
+ const items = screen.getAllByText(/Alpha|Beta|Charlie/)
+ expect(items[0].textContent).toBe('Alpha')
+ expect(items[1].textContent).toBe('Beta')
+ expect(items[2].textContent).toBe('Charlie')
+ })
+
+ it('shows type title as header for sectionGroup selection', () => {
+ const typeDoc = makeEntry({ path: '/type/project.md', title: 'Project', isA: 'Type' })
+ renderNoteList({ entries: [typeDoc], selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' } })
+ expect(screen.getByText('Project')).toBeInTheDocument()
+ })
+
+ it('migrates localStorage sort to type frontmatter when type has no sort', () => {
+ localStorageMock.setItem('laputa-sort-preferences', JSON.stringify({ '__list__': { option: 'title', direction: 'asc' } }))
+ const onUpdateTypeSort = vi.fn()
+ const updateEntry = vi.fn()
+ const typeDoc = makeEntry({ path: '/type/project.md', title: 'Project', isA: 'Type', sort: null })
+ const entries = [typeDoc, makeEntry()]
+
+ renderNoteList({
+ entries,
+ selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
+ onUpdateTypeSort,
+ updateEntry,
+ })
+
+ expect(onUpdateTypeSort).toHaveBeenCalledWith('/type/project.md', 'sort', 'title:asc')
+ expect(updateEntry).toHaveBeenCalledWith('/type/project.md', { sort: 'title:asc' })
+ })
+
+ it('does not migrate if type already has sort', () => {
+ localStorageMock.setItem('laputa-sort-preferences', JSON.stringify({ '__list__': { option: 'title', direction: 'asc' } }))
+ const onUpdateTypeSort = vi.fn()
+ const updateEntry = vi.fn()
+ const typeDoc = makeEntry({ path: '/type/project.md', title: 'Project', isA: 'Type', sort: 'modified:desc' })
+ const entries = [typeDoc, makeEntry()]
+
+ renderNoteList({
+ entries,
+ selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
+ onUpdateTypeSort,
+ updateEntry,
+ })
+
+ expect(onUpdateTypeSort).not.toHaveBeenCalled()
+ })
+
+ it('falls back to modified when property sort references missing property', () => {
+ localStorageMock.setItem('laputa-sort-preferences', JSON.stringify({ '__list__': { option: 'property:priority', direction: 'asc' } }))
+ const entries = [
+ makeEntry({ path: '/a.md', title: 'Alpha', modifiedAt: 1000, properties: {} }),
+ makeEntry({ path: '/b.md', title: 'Beta', modifiedAt: 3000, properties: {} }),
+ ]
+ renderNoteList({ entries, selection: { kind: 'filter', filter: 'all' } })
+ const items = screen.getAllByText(/Alpha|Beta/)
+ // Should be sorted by modified desc (fallback), so Beta first
+ expect(items[0].textContent).toBe('Beta')
+ expect(items[1].textContent).toBe('Alpha')
+ })
+
+ it('uses property sort when property exists in entries', () => {
+ localStorageMock.setItem('laputa-sort-preferences', JSON.stringify({ '__list__': { option: 'property:priority', direction: 'asc' } }))
+ const entries = [
+ makeEntry({ path: '/b.md', title: 'Beta', modifiedAt: 3000, properties: { priority: 2 } }),
+ makeEntry({ path: '/a.md', title: 'Alpha', modifiedAt: 1000, properties: { priority: 1 } }),
+ ]
+ renderNoteList({ entries, selection: { kind: 'filter', filter: 'all' } })
+ const items = screen.getAllByText(/Alpha|Beta/)
+ // Should be sorted by priority asc: Alpha (1) first, then Beta (2)
+ expect(items[0].textContent).toBe('Alpha')
+ expect(items[1].textContent).toBe('Beta')
+ })
+})