refactor: extract hooks and components from NoteListInner for code health

Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create

Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.

NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-05 15:53:15 +01:00
parent a3e3d9a72b
commit f8e5cbfa36
2 changed files with 334 additions and 137 deletions

View File

@@ -10,7 +10,7 @@ import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typ
import { NoteItem, getTypeIcon } from './NoteItem'
import { SortDropdown } from './SortDropdown'
import { BulkActionBar } from './BulkActionBar'
import { useMultiSelect } from '../hooks/useMultiSelect'
import { useMultiSelect, type MultiSelectState } from '../hooks/useMultiSelect'
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
import {
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
@@ -148,13 +148,20 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, query: string): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isTrashView) return 'Trash is empty'
return query ? 'No matching notes' : 'No notes found'
}
function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = (isChangesView && changesError) ? `Failed to load changes: ${changesError}` : isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, query)
const hasHeader = isTrashView && expiredTrashCount > 0
if (searched.length === 0) {
@@ -198,17 +205,16 @@ function countExpiredTrash(entries: VaultEntry[]): number {
// --- Click routing ---
type MultiSelectActions = { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
interface ClickActions {
onReplace: (entry: VaultEntry) => void
onSelect: (entry: VaultEntry) => void
multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
}
function routeNoteClick(
entry: VaultEntry, e: React.MouseEvent,
onReplaceActiveTab: (entry: VaultEntry) => void,
onSelectNote: (entry: VaultEntry) => void,
multiSelect: MultiSelectActions,
) {
if (e.shiftKey) { multiSelect.selectRange(entry.path) }
else if (e.metaKey || e.ctrlKey) { multiSelect.clear(); onSelectNote(entry) }
else { multiSelect.clear(); multiSelect.setAnchor(entry.path); onReplaceActiveTab(entry) }
function routeNoteClick(entry: VaultEntry, e: React.MouseEvent, actions: ClickActions) {
if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) }
else if (e.metaKey || e.ctrlKey) { actions.multiSelect.clear(); actions.onSelect(entry) }
else { actions.multiSelect.clear(); actions.multiSelect.setAnchor(entry.path); actions.onReplace(entry) }
}
// --- Pure helpers extracted from NoteListInner to reduce cyclomatic complexity ---
@@ -292,170 +298,162 @@ function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Recor
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
}
// --- Main component ---
// --- Extracted hooks ---
const defaultGetNoteStatus = (): NoteStatus => 'clean'
interface SortPersistence {
onUpdateTypeSort: (path: string, key: string, value: string) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
}
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
function persistSortToType(path: string, config: SortConfig, persistence: SortPersistence) {
const serialized = serializeSortConfig(config)
persistence.onUpdateTypeSort(path, 'sort', serialized)
persistence.updateEntry(path, { sort: serialized })
clearListSortFromLocalStorage()
}
function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record<string, SortConfig>, migrationDone: Set<string>, persistence: SortPersistence) {
if (typeDoc.sort || migrationDone.has(typeDoc.path)) return
const lsConfig = sortPrefs['__list__']
if (!lsConfig) return
migrationDone.add(typeDoc.path)
persistSortToType(typeDoc.path, lsConfig, persistence)
}
function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDirection, setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>) {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
if (!configOption.startsWith('property:')) return configOption
return customProperties.includes(configOption.slice('property:'.length)) ? configOption : 'modified'
}
interface UseNoteListSortParams {
entries: VaultEntry[]
selection: SidebarSelection
modifiedPathSet: Set<string>
modifiedSuffixes: string[]
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const { onMouseDown: onDragMouseDown } = useDragRegion()
const modifiedPathSet = useMemo(
() => new Set((modifiedFiles ?? []).map((f) => f.path)),
[modifiedFiles],
)
// Suffix patterns for cross-machine robustness: if the vault cache carried
// stale absolute paths from another machine, fall back to matching by the
// relative path suffix so the changes view stays in sync with the badge.
const modifiedSuffixes = useMemo(
() => (modifiedFiles ?? []).map((f) => '/' + f.relativePath),
[modifiedFiles],
)
const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(
() => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet),
[getNoteStatus, modifiedFiles, modifiedPathSet],
)
// Resolve the type document for sectionGroup selections (needs to be above sort logic)
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
// Resolve list sort config: read from type frontmatter for sectionGroup, else localStorage
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
const persistence = useMemo<SortPersistence | null>(
() => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null,
[onUpdateTypeSort, updateEntry],
)
// Silent migration: if type has no sort in frontmatter but localStorage has __list__, migrate it
const migrationDoneRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!typeDocument || typeDocument.sort || !onUpdateTypeSort || !updateEntry) return
if (migrationDoneRef.current.has(typeDocument.path)) return
const lsConfig = sortPrefs['__list__']
if (!lsConfig) return
migrationDoneRef.current.add(typeDocument.path)
const serialized = serializeSortConfig(lsConfig)
onUpdateTypeSort(typeDocument.path, 'sort', serialized)
updateEntry(typeDocument.path, { sort: serialized })
clearListSortFromLocalStorage()
}, [typeDocument, sortPrefs, onUpdateTypeSort, updateEntry])
if (!typeDocument || !persistence) return
migrateListSortToType(typeDocument, sortPrefs, migrationDoneRef.current, persistence)
}, [typeDocument, sortPrefs, persistence])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
if (groupLabel === '__list__' && typeDocument && onUpdateTypeSort && updateEntry) {
// Persist sort to type file frontmatter
const config: SortConfig = { option, direction }
const serialized = serializeSortConfig(config)
onUpdateTypeSort(typeDocument.path, 'sort', serialized)
updateEntry(typeDocument.path, { sort: serialized })
// Clear old localStorage __list__ entry if present (migration cleanup)
clearListSortFromLocalStorage()
if (groupLabel === '__list__' && typeDocument && persistence) {
persistSortToType(typeDocument.path, { option, direction }, persistence)
} else {
// Relationship group sorts still use localStorage
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
saveGroupSort(groupLabel, option, direction, setSortPrefs)
}
}, [typeDocument, onUpdateTypeSort, updateEntry])
}, [typeDocument, persistence])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => toggleSetMember(prev, label))
}, [])
const typeEntryMap = useTypeEntryMap(entries)
const query = search.trim().toLowerCase()
// Compute custom properties and derive effective sort before sorting entries
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => {
const opt = listConfig.option
if (!opt.startsWith('property:')) return opt
return customProperties.includes(opt.slice('property:'.length)) ? opt : 'modified'
}, [listConfig.option, customProperties])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const noteListKeyboard = useNoteListKeyboard({
items: searched,
selectedNotePath: selectedNote?.path ?? null,
onOpen: onReplaceActiveTab,
enabled: !isEntityView,
})
return { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument }
}
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
function useNoteListSearch() {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const query = search.trim().toLowerCase()
// Clear multi-select when sidebar selection changes
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
const toggleSearch = useCallback(() => {
setSearchVisible((v) => { if (v) setSearch(''); return !v })
}, [])
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
routeNoteClick(entry, e, onReplaceActiveTab, onSelectNote, multiSelect)
}, [onReplaceActiveTab, onSelectNote, multiSelect])
return { search, setSearch, query, searchVisible, toggleSearch }
}
const handleBulkArchive = useCallback(() => {
const paths = [...multiSelect.selectedPaths]
multiSelect.clear()
onBulkArchive?.(paths)
}, [multiSelect, onBulkArchive])
function isInputFocused(): boolean {
const el = document.activeElement
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || !!(el as HTMLElement)?.isContentEditable
}
const handleBulkTrash = useCallback(() => {
const paths = [...multiSelect.selectedPaths]
multiSelect.clear()
onBulkTrash?.(paths)
}, [multiSelect, onBulkTrash])
function handleEscapeKey(e: KeyboardEvent, multiSelect: MultiSelectState) {
if (e.key !== 'Escape' || !multiSelect.isMultiSelecting) return
e.preventDefault()
multiSelect.clear()
}
// Keyboard: Escape to clear, Cmd+A to select all, Cmd+E/Cmd+Delete for bulk actions
// Uses capture phase so bulk shortcuts preempt the global single-note handler
function handleSelectAllKey(e: KeyboardEvent, multiSelect: MultiSelectState, isEntityView: boolean) {
if (e.key !== 'a' || !(e.metaKey || e.ctrlKey) || isEntityView || isInputFocused()) return
e.preventDefault()
multiSelect.selectAll()
}
function handleBulkActionKey(e: KeyboardEvent, multiSelect: MultiSelectState, onArchive: () => void, onTrash: () => void) {
if (!multiSelect.isMultiSelecting || !(e.metaKey || e.ctrlKey)) return
if (e.key === 'e') { e.preventDefault(); e.stopPropagation(); onArchive() }
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); onTrash() }
}
function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boolean, onBulkArchive: () => void, onBulkTrash: () => void) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && multiSelect.isMultiSelecting) {
e.preventDefault()
multiSelect.clear()
}
if (e.key === 'a' && (e.metaKey || e.ctrlKey) && !isEntityView) {
const active = document.activeElement
const isInput = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || (active as HTMLElement)?.isContentEditable
if (!isInput) {
e.preventDefault()
multiSelect.selectAll()
}
}
if (multiSelect.isMultiSelecting && (e.metaKey || e.ctrlKey)) {
if (e.key === 'e') {
e.preventDefault()
e.stopPropagation()
handleBulkArchive()
} else if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
e.stopPropagation()
handleBulkTrash()
}
}
handleEscapeKey(e, multiSelect)
handleSelectAllKey(e, multiSelect, isEntityView)
handleBulkActionKey(e, multiSelect, onBulkArchive, onBulkTrash)
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [multiSelect, isEntityView, handleBulkArchive, handleBulkTrash])
}, [multiSelect, isEntityView, onBulkArchive, onBulkTrash])
}
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)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
// --- Header component ---
function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
title: string
typeDocument: VaultEntry | null
isEntityView: boolean
listSort: SortOption
listDirection: SortDirection
customProperties: string[]
sidebarCollapsed?: boolean
searchVisible: boolean
search: string
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
onCreateNote: () => void
onOpenType: (entry: VaultEntry) => void
onToggleSearch: () => void
onSearchChange: (value: string) => void
}) {
const { onMouseDown: onDragMouseDown } = useDragRegion()
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<>
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default', paddingLeft: sidebarCollapsed ? 80 : undefined }}>
<h3
className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold"
style={typeDocument ? { cursor: 'pointer' } : undefined}
onClick={typeDocument ? () => onReplaceActiveTab(typeDocument) : undefined}
onClick={typeDocument ? () => onOpenType(typeDocument) : undefined}
data-testid={typeDocument ? 'type-header-link' : undefined}
>
{resolveHeaderTitle(selection, typeDocument)}
{title}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} customProperties={customProperties} onChange={handleSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} customProperties={customProperties} onChange={onSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
<MagnifyingGlass size={16} />
</button>
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
@@ -463,21 +461,69 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
</button>
</div>
</div>
{searchVisible && (
<div className="border-b border-border px-3 py-2">
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 text-[13px]" autoFocus />
<Input placeholder="Search notes..." value={search} onChange={(e) => onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus />
</div>
)}
</>
)
}
// --- 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<Set<string>>(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) => (
<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)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [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 (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} />
<div className="flex-1 overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onClear={multiSelect.clear} />
)}

View File

@@ -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<string, string> = {}
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> = {}): 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<VaultEntry>) => void
}) {
return render(
<NoteList
entries={props.entries}
selection={props.selection}
selectedNote={null}
allContent={{}}
onSelectNote={noop}
onReplaceActiveTab={noop}
onCreateNote={noop}
onUpdateTypeSort={props.onUpdateTypeSort}
updateEntry={props.updateEntry}
/>,
)
}
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')
})
})