From 22d002c1c697c615d3af87e59a30149f0914983f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 16 Mar 2026 19:36:00 +0100 Subject: [PATCH] refactor: split NoteList.tsx god component into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 7 sub-modules under components/note-list/: - PinnedCard.tsx, RelationshipGroupSection.tsx, TrashWarningBanner.tsx - NoteListHeader.tsx, NoteListViews.tsx - noteListHooks.ts, noteListUtils.ts NoteList.tsx reduced from 579 → 95 lines. No behavior changes. All 2152 unit tests pass, 80%+ coverage, lint + typecheck clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/NoteList.tsx | 503 +----------------- src/components/note-list/NoteListHeader.tsx | 68 +++ src/components/note-list/NoteListViews.tsx | 76 +++ src/components/note-list/PinnedCard.tsx | 25 + .../note-list/RelationshipGroupSection.tsx | 38 ++ .../note-list/TrashWarningBanner.tsx | 28 + src/components/note-list/noteListHooks.ts | 212 ++++++++ src/components/note-list/noteListUtils.ts | 61 +++ .../split-notelist-god-component.spec.ts | 66 +++ 9 files changed, 584 insertions(+), 493 deletions(-) create mode 100644 src/components/note-list/NoteListHeader.tsx create mode 100644 src/components/note-list/NoteListViews.tsx create mode 100644 src/components/note-list/PinnedCard.tsx create mode 100644 src/components/note-list/RelationshipGroupSection.tsx create mode 100644 src/components/note-list/TrashWarningBanner.tsx create mode 100644 src/components/note-list/noteListHooks.ts create mode 100644 src/components/note-list/noteListUtils.ts create mode 100644 tests/smoke/split-notelist-god-component.spec.ts diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 9878ac0a..df8f2b56 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,26 +1,18 @@ -import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react' -import { useDragRegion } from '../hooks/useDragRegion' -import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' +import { useState, useMemo, useCallback, useEffect, memo } from 'react' import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types' -import { Input } from '@/components/ui/input' -import { - MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, Trash, TrashSimple, -} from '@phosphor-icons/react' -import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors' -import { NoteItem, getTypeIcon } from './NoteItem' +import { NoteItem } from './NoteItem' import { prefetchNoteContent } from '../hooks/useTabManagement' -import { SortDropdown } from './SortDropdown' import { BulkActionBar } from './BulkActionBar' -import { useMultiSelect, type MultiSelectState } from '../hooks/useMultiSelect' +import { useMultiSelect } from '../hooks/useMultiSelect' import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard' +import { NoteListHeader } from './note-list/NoteListHeader' +import { EntityView, ListView } from './note-list/NoteListViews' +import { DeletedNotesBanner } from './note-list/TrashWarningBanner' +import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils' import { - type SortOption, type SortDirection, type SortConfig, type RelationshipGroup, - getSortComparator, extractSortableProperties, - buildRelationshipGroups, filterEntries, - relativeDate, getDisplayDate, - loadSortPreferences, saveSortPreferences, - parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage, -} from '../utils/noteListHelpers' + useTypeEntryMap, useNoteListData, useNoteListSearch, + useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState, +} from './note-list/noteListHooks' interface NoteListProps { entries: VaultEntry[] @@ -42,481 +34,6 @@ interface NoteListProps { 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 DeletedNotesBanner({ count }: { count: number }) { - if (count === 0) return null - return ( -
- - {count} {count === 1 ? 'note' : 'notes'} deleted -
- ) -} - -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, deletedCount = 0, searched, query, renderItem, virtuosoRef }: { - isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number - deletedCount?: 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 - const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0 - - if (searched.length === 0 && !hasDeletedOnly) { - return ( -
- {hasHeader && } - -
- ) - } - - if (hasDeletedOnly) { - return
- } - - 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, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: { - title: string - typeDocument: VaultEntry | null - isEntityView: boolean - isTrashView: boolean - trashCount: number - 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 - onEmptyTrash?: () => void -}) { - const { onMouseDown: onDragMouseDown } = useDragRegion() - return ( - <> -
-

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

-
- {!isEntityView && } - - {isTrashView && trashCount > 0 && ( - - )} - {!isTrashView && ( - - )} -
-
- {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, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) { const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus) const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }) diff --git a/src/components/note-list/NoteListHeader.tsx b/src/components/note-list/NoteListHeader.tsx new file mode 100644 index 00000000..27c5fbcc --- /dev/null +++ b/src/components/note-list/NoteListHeader.tsx @@ -0,0 +1,68 @@ +import { MagnifyingGlass, Plus, Trash } from '@phosphor-icons/react' +import type { VaultEntry } from '../../types' +import type { SortOption, SortDirection } from '../../utils/noteListHelpers' +import { Input } from '@/components/ui/input' +import { useDragRegion } from '../../hooks/useDragRegion' +import { SortDropdown } from '../SortDropdown' + +export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: { + title: string + typeDocument: VaultEntry | null + isEntityView: boolean + isTrashView: boolean + trashCount: number + 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 + onEmptyTrash?: () => void +}) { + const { onMouseDown: onDragMouseDown } = useDragRegion() + return ( + <> +
+

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

+
+ {!isEntityView && } + + {isTrashView && trashCount > 0 && ( + + )} + {!isTrashView && ( + + )} +
+
+ {searchVisible && ( +
+ onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus /> +
+ )} + + ) +} diff --git a/src/components/note-list/NoteListViews.tsx b/src/components/note-list/NoteListViews.tsx new file mode 100644 index 00000000..a3f07a43 --- /dev/null +++ b/src/components/note-list/NoteListViews.tsx @@ -0,0 +1,76 @@ +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' +import type { VaultEntry } from '../../types' +import type { SortOption, SortDirection, SortConfig, RelationshipGroup } from '../../utils/noteListHelpers' +import { PinnedCard } from './PinnedCard' +import { RelationshipGroupSection } from './RelationshipGroupSection' +import { TrashWarningBanner, EmptyMessage } from './TrashWarningBanner' + +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' +} + +export 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} /> + )) + } +
+ ) +} + +export function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: { + isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number + deletedCount?: 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 + const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0 + + if (searched.length === 0 && !hasDeletedOnly) { + return ( +
+ {hasHeader && } + +
+ ) + } + + if (hasDeletedOnly) { + return
+ } + + return ( + : undefined, + }} + itemContent={(_index, entry) => renderItem(entry)} + /> + ) +} diff --git a/src/components/note-list/PinnedCard.tsx b/src/components/note-list/PinnedCard.tsx new file mode 100644 index 00000000..db9bc09e --- /dev/null +++ b/src/components/note-list/PinnedCard.tsx @@ -0,0 +1,25 @@ +import type { VaultEntry } from '../../types' +import { getTypeColor, getTypeLightColor } from '../../utils/typeColors' +import { getTypeIcon } from '../NoteItem' +import { relativeDate, getDisplayDate } from '../../utils/noteListHelpers' + +export 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))}
} +
+ ) +} diff --git a/src/components/note-list/RelationshipGroupSection.tsx b/src/components/note-list/RelationshipGroupSection.tsx new file mode 100644 index 00000000..28caaaa7 --- /dev/null +++ b/src/components/note-list/RelationshipGroupSection.tsx @@ -0,0 +1,38 @@ +import { useMemo } from 'react' +import { CaretDown, CaretRight } from '@phosphor-icons/react' +import type { VaultEntry } from '../../types' +import { + type SortOption, type SortDirection, type SortConfig, type RelationshipGroup, + getSortComparator, extractSortableProperties, +} from '../../utils/noteListHelpers' +import { SortDropdown } from '../SortDropdown' + +export 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))} +
+ ) +} diff --git a/src/components/note-list/TrashWarningBanner.tsx b/src/components/note-list/TrashWarningBanner.tsx new file mode 100644 index 00000000..d884bf1f --- /dev/null +++ b/src/components/note-list/TrashWarningBanner.tsx @@ -0,0 +1,28 @@ +import { Warning, TrashSimple } from '@phosphor-icons/react' + +export 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
+
+
+ ) +} + +export function EmptyMessage({ text }: { text: string }) { + return
{text}
+} + +export function DeletedNotesBanner({ count }: { count: number }) { + if (count === 0) return null + return ( +
+ + {count} {count === 1 ? 'note' : 'notes'} deleted +
+ ) +} diff --git a/src/components/note-list/noteListHooks.ts b/src/components/note-list/noteListHooks.ts new file mode 100644 index 00000000..48cc0f71 --- /dev/null +++ b/src/components/note-list/noteListHooks.ts @@ -0,0 +1,212 @@ +import { useState, useMemo, useCallback, useEffect, useRef } from 'react' +import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types' +import { + type SortOption, type SortDirection, type SortConfig, + getSortComparator, extractSortableProperties, + buildRelationshipGroups, filterEntries, + loadSortPreferences, saveSortPreferences, + parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage, +} from '../../utils/noteListHelpers' +import { buildTypeEntryMap } from '../../utils/typeColors' +import { filterByQuery, filterGroupsByQuery, countExpiredTrash, createNoteStatusResolver, isModifiedEntry } from './noteListUtils' +import type { MultiSelectState } from '../../hooks/useMultiSelect' + +// --- useTypeEntryMap --- + +export function useTypeEntryMap(entries: VaultEntry[]) { + return useMemo(() => buildTypeEntryMap(entries), [entries]) +} + +// --- useFilteredEntries --- + +export 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]) +} + +// --- useNoteListData --- + +interface NoteListDataParams { + entries: VaultEntry[]; selection: SidebarSelection + query: string; listSort: SortOption; listDirection: SortDirection + modifiedPathSet: Set; modifiedSuffixes: string[] +} + +export 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 } +} + +// --- useNoteListSearch --- + +export 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 } +} + +// --- useNoteListSort --- + +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 +} + +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' +} + +export 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 +} + +export 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 } +} + +// --- useMultiSelectKeyboard --- + +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() } +} + +export 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]) +} + +// --- useModifiedFilesState --- + +export 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 } +} diff --git a/src/components/note-list/noteListUtils.ts b/src/components/note-list/noteListUtils.ts new file mode 100644 index 00000000..1fc73c2d --- /dev/null +++ b/src/components/note-list/noteListUtils.ts @@ -0,0 +1,61 @@ +import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types' +import type { RelationshipGroup } from '../../utils/noteListHelpers' + +export 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' +} + +export function filterByQuery(items: T[], query: string): T[] { + return query ? items.filter((e) => e.title.toLowerCase().includes(query)) : items +} + +export 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) +} + +export function countExpiredTrash(entries: VaultEntry[]): number { + const now = Date.now() / 1000 + return entries.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length +} + +export interface ClickActions { + onReplace: (entry: VaultEntry) => void + onSelect: (entry: VaultEntry) => void + multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void } +} + +export 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) } +} + +export 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 () => 'clean' +} + +export 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 +} + +export function isModifiedEntry(path: string, pathSet: Set, suffixes: string[]): boolean { + if (pathSet.has(path)) return true + return suffixes.some((suffix) => path.endsWith(suffix)) +} diff --git a/tests/smoke/split-notelist-god-component.spec.ts b/tests/smoke/split-notelist-god-component.spec.ts new file mode 100644 index 00000000..1bd14298 --- /dev/null +++ b/tests/smoke/split-notelist-god-component.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test' + +test.describe('NoteList split refactor – no behavior changes', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('note list container renders and shows notes', async ({ page }) => { + const container = page.locator('[data-testid="note-list-container"]') + await expect(container).toBeVisible() + + // Notes should render inside the container + const noteItems = container.locator('.cursor-pointer') + await expect(noteItems.first()).toBeVisible({ timeout: 5000 }) + const count = await noteItems.count() + expect(count).toBeGreaterThan(0) + }) + + test('header with title and action buttons renders', async ({ page }) => { + // The header area sits above the note-list-container: h3 title + action buttons + const header = page.locator('h3.truncate') + await expect(header).toBeVisible() + const title = await header.textContent() + expect(title && title.length > 0).toBe(true) + + // Search button (magnifying glass) should be present + const searchBtn = page.locator('button[title="Search notes"]') + await expect(searchBtn).toBeVisible() + + // Create note button should be present (when not in trash view) + const createBtn = page.locator('button[title="Create new note"]') + await expect(createBtn).toBeVisible() + }) + + test('search toggle shows and hides search input', async ({ page }) => { + const searchBtn = page.locator('button[title="Search notes"]') + await searchBtn.click() + + const searchInput = page.locator('input[placeholder="Search notes..."]') + await expect(searchInput).toBeVisible() + + // Toggle off + await searchBtn.click() + await expect(searchInput).not.toBeVisible() + }) + + test('clicking a note opens it in the editor', async ({ page }) => { + const container = page.locator('[data-testid="note-list-container"]') + await expect(container).toBeVisible() + + const noteItems = container.locator('.cursor-pointer') + await expect(noteItems.first()).toBeVisible({ timeout: 5000 }) + + // Get the title of the first note + const noteTitle = await noteItems.first().locator('.font-medium, .font-semibold, .font-bold').first().textContent() + + // Click the first note + await noteItems.first().click() + + // After click, the editor area or tab bar should reflect the opened note + // Verify the note list container is still functional (no crash from refactor) + await expect(container).toBeVisible() + expect(noteTitle && noteTitle.length > 0).toBe(true) + }) +})