import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react' import { useDragRegion } from '../hooks/useDragRegion' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types' import { Input } from '@/components/ui/input' import { MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, } from '@phosphor-icons/react' import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors' import { NoteItem, getTypeIcon } from './NoteItem' import { prefetchNoteContent } from '../hooks/useTabManagement' import { SortDropdown } from './SortDropdown' import { BulkActionBar } from './BulkActionBar' import { useMultiSelect, type MultiSelectState } from '../hooks/useMultiSelect' import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard' import { type SortOption, type SortDirection, type SortConfig, type RelationshipGroup, getSortComparator, extractSortableProperties, buildRelationshipGroups, filterEntries, relativeDate, getDisplayDate, loadSortPreferences, saveSortPreferences, parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage, } from '../utils/noteListHelpers' interface NoteListProps { entries: VaultEntry[] selection: SidebarSelection selectedNote: VaultEntry | null modifiedFiles?: ModifiedFile[] modifiedFilesError?: string | null getNoteStatus?: (path: string) => NoteStatus sidebarCollapsed?: boolean onSelectNote: (entry: VaultEntry) => void onReplaceActiveTab: (entry: VaultEntry) => void onCreateNote: () => void onBulkArchive?: (paths: string[]) => void onBulkTrash?: (paths: string[]) => void onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void updateEntry?: (path: string, patch: Partial) => void } function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: { entry: VaultEntry typeEntryMap: Record onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void showDate?: boolean }) { const te = typeEntryMap[entry.isA ?? ''] const color = getTypeColor(entry.isA ?? '', te?.color) const bgColor = getTypeLightColor(entry.isA ?? '', te?.color) const Icon = getTypeIcon(entry.isA, te?.icon) return (
onClickNote(entry, e)}> {/* eslint-disable-next-line react-hooks/static-components */}
{entry.title}
{entry.snippet}
{showDate &&
{relativeDate(getDisplayDate(entry))}
}
) } function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: { group: RelationshipGroup isCollapsed: boolean sortPrefs: Record onToggle: () => void handleSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void renderItem: (entry: VaultEntry) => React.ReactNode }) { const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection } const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction)) const customProperties = useMemo(() => extractSortableProperties(group.entries), [group.entries]) return (
{!isCollapsed && sortedEntries.map((entry) => renderItem(entry))}
) } function TrashWarningBanner({ expiredCount }: { expiredCount: number }) { if (expiredCount === 0) return null return (
Notes in trash for 30+ days will be permanently deleted
{expiredCount} {expiredCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period
) } function EmptyMessage({ text }: { text: string }) { return
{text}
} function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null): string { if (selection.kind === 'entity') return selection.entry.title if (typeDocument) return typeDocument.title if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive' if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash' if (selection.kind === 'filter' && selection.filter === 'changes') return 'Changes' return 'Notes' } function useTypeEntryMap(entries: VaultEntry[]) { return useMemo(() => buildTypeEntryMap(entries), [entries]) } // --- View sub-components --- function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onClickNote }: { entity: VaultEntry; groups: RelationshipGroup[]; query: string collapsedGroups: Set; sortPrefs: Record onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void renderItem: (entry: VaultEntry) => React.ReactNode typeEntryMap: Record; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void }) { return (
{groups.length === 0 ? : groups.map((group) => ( onToggleGroup(group.label)} handleSortChange={onSortChange} renderItem={renderItem} /> )) }
) } function ListViewHeader({ isTrashView, expiredTrashCount }: { isTrashView: boolean; expiredTrashCount: number }) { return } 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 }) { const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, query) const hasHeader = isTrashView && expiredTrashCount > 0 if (searched.length === 0) { return (
{hasHeader && }
) } return ( : undefined, }} itemContent={(_index, entry) => renderItem(entry)} /> ) } // --- Pure helpers --- function filterByQuery(items: T[], query: string): T[] { return query ? items.filter((e) => e.title.toLowerCase().includes(query)) : items } function filterGroupsByQuery(groups: RelationshipGroup[], query: string): RelationshipGroup[] { if (!query) return groups return groups.map((g) => ({ ...g, entries: filterByQuery(g.entries, query) })).filter((g) => g.entries.length > 0) } function countExpiredTrash(entries: VaultEntry[]): number { const now = Date.now() / 1000 return entries.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length } // --- Click routing --- 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, 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 --- function createNoteStatusResolver( getNoteStatus: ((path: string) => NoteStatus) | undefined, modifiedFiles: ModifiedFile[] | undefined, modifiedPathSet: Set, ): (path: string) => NoteStatus { if (getNoteStatus) return getNoteStatus if (modifiedFiles && modifiedFiles.length > 0) { return (path: string) => modifiedPathSet.has(path) ? 'modified' : 'clean' } return defaultGetNoteStatus } function toggleSetMember(set: Set, member: T): Set { const next = new Set(set) if (next.has(member)) next.delete(member) else next.add(member) return next } // --- Data hooks --- interface NoteListDataParams { entries: VaultEntry[]; selection: SidebarSelection query: string; listSort: SortOption; listDirection: SortDirection modifiedPathSet: Set; modifiedSuffixes: string[] } function isModifiedEntry(path: string, pathSet: Set, suffixes: string[]): boolean { if (pathSet.has(path)) return true return suffixes.some((suffix) => path.endsWith(suffix)) } function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set, modifiedSuffixes: string[]) { const isEntityView = selection.kind === 'entity' const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' return useMemo(() => { if (isEntityView) return [] if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes)) return filterEntries(entries, selection) }, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes]) } function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes) const searched = useMemo(() => { const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection)) return filterByQuery(sorted, query) }, [filteredEntries, listSort, listDirection, query]) const searchedGroups = useMemo(() => { if (!isEntityView) return [] const groups = buildRelationshipGroups(selection.entry, entries) return filterGroupsByQuery(groups, query) }, [isEntityView, selection, entries, query]) const expiredTrashCount = useMemo( () => isTrashView ? countExpiredTrash(searched) : 0, [isTrashView, searched], ) return { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } } // --- Pure helpers --- const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' } function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record): SortConfig { if (typeDocument?.sort) { const parsed = parseSortConfig(typeDocument.sort) if (parsed) return parsed } return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG } // --- Extracted hooks --- interface SortPersistence { onUpdateTypeSort: (path: string, key: string, value: string) => void updateEntry: (path: string, patch: Partial) => void } 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, migrationDone: Set, 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>>) { 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 modifiedSuffixes: string[] onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void updateEntry?: (path: string, patch: Partial) => void } function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) { const [sortPrefs, setSortPrefs] = useState>(loadSortPreferences) const typeDocument = useMemo(() => { if (selection.kind !== 'sectionGroup') return null return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null }, [selection, entries]) const listConfig = resolveListSortConfig(typeDocument, sortPrefs) const persistence = useMemo( () => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null, [onUpdateTypeSort, updateEntry], ) const migrationDoneRef = useRef>(new Set()) useEffect(() => { 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 && persistence) { persistSortToType(typeDocument.path, { option, direction }, persistence) } else { saveGroupSort(groupLabel, option, direction, setSortPrefs) } }, [typeDocument, persistence]) const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes) const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries]) const listSort = useMemo(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties]) const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc' return { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } } function useNoteListSearch() { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const query = search.trim().toLowerCase() const toggleSearch = useCallback(() => { setSearchVisible((v) => { if (v) setSearch(''); return !v }) }, []) return { search, setSearch, query, searchVisible, toggleSearch } } function isInputFocused(): boolean { const el = document.activeElement return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || !!(el as HTMLElement)?.isContentEditable } function handleEscapeKey(e: KeyboardEvent, multiSelect: MultiSelectState) { if (e.key !== 'Escape' || !multiSelect.isMultiSelecting) return e.preventDefault() multiSelect.clear() } 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) { 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, onBulkArchive, onBulkTrash]) } // --- 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 ( <>

onOpenType(typeDocument) : undefined} data-testid={typeDocument ? 'type-header-link' : undefined} > {title}

{!isEntityView && }
{searchVisible && (
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, 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, 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 (
{entitySelection ? ( ) : ( )}
{multiSelect.isMultiSelecting && ( )}
) } export const NoteList = memo(NoteListInner)