import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react' import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' import { countByFilter, countAllByFilter } from '../utils/noteListHelpers' import { NoteItem } from './NoteItem' import { prefetchNoteContent } from '../hooks/useTabManagement' import { BulkActionBar } from './BulkActionBar' import { useMultiSelect } from '../hooks/useMultiSelect' import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard' import { NoteListHeader } from './note-list/NoteListHeader' import { FilterPills } from './note-list/FilterPills' import { EntityView, ListView } from './note-list/NoteListViews' import { DeletedNotesBanner } from './note-list/TrashWarningBanner' import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils' import { useTypeEntryMap, useNoteListData, useNoteListSearch, useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState, } from './note-list/noteListHooks' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' function useViewFlags(selection: SidebarSelection) { const isSectionGroup = selection.kind === 'sectionGroup' const isFolderView = selection.kind === 'folder' const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox' const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all' const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const showFilterPills = isSectionGroup || isFolderView || isAllNotesView return { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } } function useBulkActions( multiSelect: ReturnType, onBulkArchive: NoteListProps['onBulkArchive'], onBulkTrash: NoteListProps['onBulkTrash'], onBulkRestore: NoteListProps['onBulkRestore'], onBulkDeletePermanently: NoteListProps['onBulkDeletePermanently'], isTrashView: boolean, isArchivedView: boolean, ) { 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]) const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore]) const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently]) const handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore]) const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : isArchivedView ? handleBulkUnarchive : handleBulkArchive const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash return { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete } } function ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }: { isChangesView: boolean; onDiscardFile?: (relativePath: string) => Promise; modifiedFiles?: ModifiedFile[] }) { const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null) const [discardTarget, setDiscardTarget] = useState(null) const ctxMenuRef = useRef(null) const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => { if (!isChangesView || !onDiscardFile) return e.preventDefault() e.stopPropagation() setCtxMenu({ x: e.clientX, y: e.clientY, entry }) }, [isChangesView, onDiscardFile]) const closeCtxMenu = useCallback(() => setCtxMenu(null), []) useEffect(() => { if (!ctxMenu) return const handler = (e: MouseEvent) => { if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) closeCtxMenu() } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [ctxMenu, closeCtxMenu]) const handleDiscardConfirm = useCallback(async () => { if (!discardTarget || !onDiscardFile) return const mf = modifiedFiles?.find((f) => f.path === discardTarget.path) if (!mf) return await onDiscardFile(mf.relativePath) setDiscardTarget(null) }, [discardTarget, onDiscardFile, modifiedFiles]) const contextMenuNode = ctxMenu ? (
) : null const dialogNode = ( { if (!open) setDiscardTarget(null) }}> Discard changes Discard changes to {discardTarget?.title ?? 'this file'}? This cannot be undone. ) return { handleNoteContextMenu, contextMenuNode, dialogNode } } interface NoteListProps { entries: VaultEntry[] selection: SidebarSelection selectedNote: VaultEntry | null noteListFilter: NoteListFilter onNoteListFilterChange: (filter: NoteListFilter) => void inboxPeriod?: InboxPeriod onInboxPeriodChange?: (period: InboxPeriod) => void modifiedFiles?: ModifiedFile[] modifiedFilesError?: string | null getNoteStatus?: (path: string) => NoteStatus sidebarCollapsed?: boolean onSelectNote: (entry: VaultEntry) => void onReplaceActiveTab: (entry: VaultEntry) => void onCreateNote: (type?: string) => void onBulkArchive?: (paths: string[]) => void onBulkTrash?: (paths: string[]) => void onBulkRestore?: (paths: string[]) => void onBulkDeletePermanently?: (paths: string[]) => void onEmptyTrash?: () => void onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void updateEntry?: (path: string, patch: Partial) => void onOpenInNewWindow?: (entry: VaultEntry) => void onDiscardFile?: (relativePath: string) => Promise onAutoTriggerDiff?: () => void views?: ViewFile[] visibleNotesRef?: React.MutableRefObject } function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views, visibleNotesRef }: NoteListProps) { const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus) const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection) const subFilter = showFilterPills ? noteListFilter : undefined const filterCounts = useMemo( () => isSectionGroup && selection.kind === 'sectionGroup' ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 }, [entries, isSectionGroup, isAllNotesView, isFolderView, selection], ) const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry }) const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch() const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const typeEntryMap = useTypeEntryMap(entries) const changeStatusMap = useMemo(() => { if (!isChangesView || !modifiedFiles) return undefined const map = new Map() for (const mf of modifiedFiles) { map.set(mf.path, mf.status) // Also index by suffix for matching (vault entries may use different path formats) map.set('/' + mf.relativePath, mf.status) } return map }, [isChangesView, modifiedFiles]) const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views }) // Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow) if (visibleNotesRef) { visibleNotesRef.current = isEntityView ? searchedGroups.flatMap((g) => g.entries) : searched } const deletedCount = useMemo( () => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0, [isChangesView, modifiedFiles], ) 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, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => { routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect }) if (isChangesView && onAutoTriggerDiff) { // Small delay to let the tab open before triggering diff setTimeout(onAutoTriggerDiff, 50) } }, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff]) const { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete } = useBulkActions(multiSelect, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, isTrashView, isArchivedView) useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete) const { handleNoteContextMenu, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }) const getChangeStatus = useCallback((path: string) => { if (!changeStatusMap) return undefined const direct = changeStatusMap.get(path) if (direct) return direct // Try suffix match for (const [key, status] of changeStatusMap) { if (path.endsWith(key) || key.endsWith(path.split('/').slice(-1)[0])) return status } return undefined }, [changeStatusMap]) const renderItem = useCallback((entry: VaultEntry) => ( ), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu]) const handleCreateNote = useCallback(() => { onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined) }, [onCreateNote, selection]) const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, []) const title = resolveHeaderTitle(selection, typeDocument, views) return (
{entitySelection ? ( ) : ( )}
{isChangesView && deletedCount > 0 && } {showFilterPills && }
{multiSelect.isMultiSelecting && ( )} {contextMenuNode} {dialogNode}
) } export const NoteList = memo(NoteListInner)