import { useState, useMemo, useCallback, useEffect, 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 { SortDropdown } from './SortDropdown' import { BulkActionBar } from './BulkActionBar' import { useMultiSelect } from '../hooks/useMultiSelect' import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard' import { type SortOption, type SortDirection, type SortConfig, type RelationshipGroup, getSortComparator, buildRelationshipGroups, filterEntries, relativeDate, getDisplayDate, loadSortPreferences, saveSortPreferences, } from '../utils/noteListHelpers' interface NoteListProps { entries: VaultEntry[] selection: SidebarSelection selectedNote: VaultEntry | null allContent: Record 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 } 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)) 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 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 = (isChangesView && changesError) ? `Failed to load changes: ${changesError}` : isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found') 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 --- type MultiSelectActions = { 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) } } // --- 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; allContent: Record 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 useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const typeDocument = useMemo(() => { if (selection.kind !== 'sectionGroup') return null return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null }, [selection, entries]) const searched = useMemo(() => { if (isEntityView) return [] if (isChangesView) { const sorted = [...entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))].sort(getSortComparator(listSort, listDirection)) return filterByQuery(sorted, query) } const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort, listDirection)) return filterByQuery(sorted, query) }, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet, modifiedSuffixes]) const searchedGroups = useMemo(() => { if (!isEntityView) return [] const groups = buildRelationshipGroups(selection.entry, entries, allContent) return filterGroupsByQuery(groups, query) }, [isEntityView, selection, entries, allContent, query]) const expiredTrashCount = useMemo( () => isTrashView ? countExpiredTrash(searched) : 0, [isTrashView, searched], ) return { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } } // --- Main component --- const defaultGetNoteStatus = (): NoteStatus => 'clean' function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const [sortPrefs, setSortPrefs] = useState>(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], ) const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => { setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next }) }, []) const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, []) const typeEntryMap = useTypeEntryMap(entries) const query = search.trim().toLowerCase() const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection } const listSort = listConfig.option const listDirection = listConfig.direction const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }) const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView, }) const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null) // Clear multi-select when sidebar selection changes 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, onReplaceActiveTab, 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]) // 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 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() } } } window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) }, [multiSelect, isEntityView, handleBulkArchive, handleBulkTrash]) const renderItem = useCallback((entry: VaultEntry) => ( ), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath]) return (

onReplaceActiveTab(typeDocument) : undefined} data-testid={typeDocument ? 'type-header-link' : undefined} > {resolveHeaderTitle(selection, typeDocument)}

{!isEntityView && }
{searchVisible && (
setSearch(e.target.value)} className="h-8 text-[13px]" autoFocus />
)}
{isEntityView && selection.kind === 'entity' ? ( ) : ( )}
{multiSelect.isMultiSelecting && ( )}
) } export const NoteList = memo(NoteListInner)