import { useState, useMemo, useCallback, memo } from 'react' import { Virtuoso } from 'react-virtuoso' import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types' import { Input } from '@/components/ui/input' import { MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, } from '@phosphor-icons/react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { NoteItem, getTypeIcon } from './NoteItem' import { SortDropdown } from './SortDropdown' 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[] onSelectNote: (entry: VaultEntry) => void onReplaceActiveTab: (entry: VaultEntry) => void onCreateNote: () => 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' return 'Notes' } function useTypeEntryMap(entries: VaultEntry[]) { return useMemo(() => { const map: Record = {} for (const e of entries) { if (e.isA === 'Type') map[e.title] = e } return map }, [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({ typeDocument, isTrashView, expiredTrashCount, typeEntryMap, onClickNote }: { typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number typeEntryMap: Record; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void }) { return ( <> {typeDocument && } ) } function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: { typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number searched: VaultEntry[]; query: string renderItem: (entry: VaultEntry) => React.ReactNode typeEntryMap: Record; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void }) { const emptyText = isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found') const hasHeader = typeDocument || (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 --- function routeNoteClick( entry: VaultEntry, e: React.MouseEvent, onSelectNote: (entry: VaultEntry) => void, onReplaceActiveTab: (entry: VaultEntry) => void, ) { if (e.metaKey || e.ctrlKey) { onSelectNote(entry) } else { onReplaceActiveTab(entry) } } // --- Data hooks --- interface NoteListDataParams { entries: VaultEntry[]; selection: SidebarSelection; allContent: Record query: string; listSort: SortOption; listDirection: SortDirection } function useNoteListData({ entries, selection, allContent, query, listSort, listDirection }: NoteListDataParams) { const isEntityView = selection.kind === 'entity' const isTrashView = selection.kind === 'filter' && selection.filter === 'trash' 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 [] const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort, listDirection)) return filterByQuery(sorted, query) }, [entries, selection, isEntityView, listSort, listDirection, query]) 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, typeDocument, searched, searchedGroups, expiredTrashCount } } // --- Main component --- function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onReplaceActiveTab, onCreateNote }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const [sortPrefs, setSortPrefs] = useState>(loadSortPreferences) 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) => { const next = new Set(prev); if (next.has(label)) { next.delete(label) } else { next.add(label) }; return next }) }, []) 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, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles }) const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => { routeNoteClick(entry, e, onSelectNote, onReplaceActiveTab) }, [onSelectNote, onReplaceActiveTab]) const renderItem = useCallback((entry: VaultEntry) => ( ), [selectedNote?.path, handleClickNote, typeEntryMap]) return (

{resolveHeaderTitle(selection, typeDocument)}

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