diff --git a/.claude-done b/.claude-done index 0b0ce8fa..93c889f0 100644 --- a/.claude-done +++ b/.claude-done @@ -1,3 +1,18 @@ -Task: vault-picker-local -Summary: Added local vault creation options to vault picker -Commits: 5 +Task: performance-note-list +Summary: Implemented virtual list rendering for NoteList using react-virtuoso. + +Changes: +- NoteList ListView now uses component instead of .map() for flat lists +- Only visible items are rendered in the DOM (overscan: 200px) +- EntityView (relationship groups) unchanged — groups are small (<100 items) +- mock-tauri.ts generates 9000 bulk entries for testing +- Test setup mocks react-virtuoso for JSDOM compatibility +- 7 new tests covering large dataset rendering, search, sorting, filtering +- Design file: design/performance-note-list.pen (4 frames) +- Docs updated: ARCHITECTURE.md notes virtual rendering approach + +Product decisions: +- Only virtualized ListView (flat lists), not EntityView — relationship groups + are inherently small and do not need virtualization +- Used overscan={200} for smooth scrolling experience +- PinnedCard and TrashWarningBanner rendered as Virtuoso Header component diff --git a/design/performance-note-list.pen b/design/performance-note-list.pen new file mode 100644 index 00000000..afa6e41f --- /dev/null +++ b/design/performance-note-list.pen @@ -0,0 +1,273 @@ +{ + "version": "2.8", + "children": [ + { + "type": "frame", + "id": "vl001", + "x": 0, + "y": 0, + "name": "Virtual List — Default State (9000+ notes)", + "theme": {"Mode": "Light"}, + "clip": true, + "width": 300, + "height": 900, + "fill": "$--card", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vl002", + "name": "Header", + "width": "fill_container", + "height": 45, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [0, 16], + "alignItems": "center", + "children": [ + {"type": "text", "id": "vl003", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground"}, + {"type": "text", "id": "vl004", "text": "9,236 items", "fontSize": 11, "fill": "$--muted-foreground", "x": 200} + ] + }, + { + "type": "frame", + "id": "vl005", + "name": "Virtualized Viewport", + "width": "fill_container", + "height": "fill_container", + "fill": "$--card", + "layout": "vertical", + "clip": true, + "children": [ + {"type": "frame", "id": "vl006", "name": "NoteItem-visible-1", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl007", "text": "Build Laputa App", "fontSize": 13, "fontWeight": 600, "fill": "$--foreground"}, + {"type": "text", "id": "vl008", "text": "This paragraph has bold text, italic text...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl009", "text": "2m ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl010", "name": "NoteItem-visible-2", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl011", "text": "Facebook Ads Strategy", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl012", "text": "Lookalike audiences convert 3x better...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl013", "text": "5h ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl014", "name": "NoteItem-visible-3", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl015", "text": "Quick Meeting 1", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl016", "text": "Key findings from the latest analysis...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl017", "text": "1d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl018", "name": "NoteItem-visible-4", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl019", "text": "Advanced Strategy 2", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl020", "text": "Notes on process improvements...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl021", "text": "2d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl022", "name": "NoteItem-visible-5-selected", "width": "fill_container", "height": 72, "fill": "#2383E210", "stroke": {"align": "inside", "thickness": {"left": 3, "bottom": 1}, "fill": "$--accent-blue"}, "padding": [14, 13], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl023", "text": "Daily Review 3", "fontSize": 13, "fontWeight": 600, "fill": "$--foreground"}, + {"type": "text", "id": "vl024", "text": "Summary of decisions made during review...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl025", "text": "3d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl026", "name": "NoteItem-visible-6", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl027", "text": "Weekly Plan 4", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl028", "text": "Action items and follow-ups...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl029", "text": "4d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl030", "name": "scrollbar-indicator", "width": 6, "height": 40, "fill": "$--muted-foreground", "opacity": 0.3, "cornerRadius": 3, "x": 290, "y": 200} + ] + } + ] + }, + { + "type": "frame", + "id": "vl100", + "x": 340, + "y": 0, + "name": "Virtual List — Scrolled Mid-list", + "theme": {"Mode": "Light"}, + "clip": true, + "width": 300, + "height": 900, + "fill": "$--card", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vl101", + "name": "Header", + "width": "fill_container", + "height": 45, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [0, 16], + "alignItems": "center", + "children": [ + {"type": "text", "id": "vl102", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground"} + ] + }, + { + "type": "frame", + "id": "vl103", + "name": "Virtualized Viewport — items 4500-4512", + "width": "fill_container", + "height": "fill_container", + "fill": "$--card", + "layout": "vertical", + "clip": true, + "children": [ + {"type": "frame", "id": "vl104", "name": "NoteItem-mid-1", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl105", "text": "Final Report 4501", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl106", "text": "Draft outline for upcoming deliverable.", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl107", "text": "Jan 15", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl108", "name": "NoteItem-mid-2", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl109", "text": "Revised Checklist 4502", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl110", "text": "Reference material for ongoing initiative.", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl111", "text": "Jan 14", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl112", "name": "NoteItem-mid-3", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl113", "text": "Archived Template 4503", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl114", "text": "Tracking progress on quarterly objectives.", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl115", "text": "Jan 13", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl116", "name": "NoteItem-mid-4", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl117", "text": "New Framework 4504", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl118", "text": "Comparison of different approaches.", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl119", "text": "Jan 12", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl120", "name": "scrollbar-indicator-mid", "width": 6, "height": 40, "fill": "$--muted-foreground", "opacity": 0.3, "cornerRadius": 3, "x": 290, "y": 440} + ] + } + ] + }, + { + "type": "frame", + "id": "vl200", + "x": 680, + "y": 0, + "name": "Virtual List — Search Filtering", + "theme": {"Mode": "Light"}, + "clip": true, + "width": 300, + "height": 900, + "fill": "$--card", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vl201", + "name": "Header", + "width": "fill_container", + "height": 45, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [0, 16], + "alignItems": "center", + "children": [ + {"type": "text", "id": "vl202", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground"} + ] + }, + { + "type": "frame", + "id": "vl203", + "name": "Search Bar", + "width": "fill_container", + "height": 40, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [8, 12], + "alignItems": "center", + "children": [ + {"type": "frame", "id": "vl204", "name": "search-input", "width": "fill_container", "height": 32, "fill": "$--background", "cornerRadius": 6, "stroke": {"align": "inside", "thickness": 1, "fill": "$--border"}, "padding": [0, 8], "alignItems": "center", "children": [ + {"type": "text", "id": "vl205", "text": "strategy", "fontSize": 13, "fill": "$--foreground"} + ]} + ] + }, + { + "type": "frame", + "id": "vl206", + "name": "Filtered Results (23 matches)", + "width": "fill_container", + "height": "fill_container", + "fill": "$--card", + "layout": "vertical", + "clip": true, + "children": [ + {"type": "frame", "id": "vl207", "name": "NoteItem-filtered-1", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl208", "text": "Facebook Ads Strategy", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl209", "text": "Lookalike audiences convert 3x better...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl210", "text": "5h ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl211", "name": "NoteItem-filtered-2", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl212", "text": "Advanced Strategy 2", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl213", "text": "Notes on process improvements...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl214", "text": "2d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl215", "name": "NoteItem-filtered-3", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl216", "text": "Quick Strategy 10", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl217", "text": "Key findings from the latest analysis...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl218", "text": "Jan 5", "fontSize": 10, "fill": "$--muted-foreground"} + ]} + ] + } + ] + }, + { + "type": "frame", + "id": "vl300", + "x": 1020, + "y": 0, + "name": "Virtual List — Empty Search Result", + "theme": {"Mode": "Light"}, + "clip": true, + "width": 300, + "height": 400, + "fill": "$--card", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vl301", + "name": "Header", + "width": "fill_container", + "height": 45, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [0, 16], + "alignItems": "center", + "children": [ + {"type": "text", "id": "vl302", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground"} + ] + }, + { + "type": "frame", + "id": "vl303", + "name": "Search Bar", + "width": "fill_container", + "height": 40, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [8, 12], + "alignItems": "center", + "children": [ + {"type": "frame", "id": "vl304", "name": "search-input", "width": "fill_container", "height": 32, "fill": "$--background", "cornerRadius": 6, "stroke": {"align": "inside", "thickness": 1, "fill": "$--border"}, "padding": [0, 8], "alignItems": "center", "children": [ + {"type": "text", "id": "vl305", "text": "xyznonexistent", "fontSize": 13, "fill": "$--foreground"} + ]} + ] + }, + { + "type": "frame", + "id": "vl306", + "name": "Empty State", + "width": "fill_container", + "height": "fill_container", + "fill": "$--card", + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "padding": [32, 16], + "children": [ + {"type": "text", "id": "vl307", "text": "No matching notes", "fontSize": 13, "fill": "$--muted-foreground", "textAlign": "center"} + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6e9393d..754aa19d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -74,7 +74,7 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault ``` - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.) -- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, and relationship groups. +- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, and relationship groups. The flat list view (All Notes, type sections, etc.) uses **react-virtuoso** for virtual rendering — only visible items are in the DOM, enabling smooth scrolling with 9000+ notes. Entity/relationship views are not virtualized (groups are small). - **Editor** (flex, fills remaining space): Tab bar, breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files. - **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them. diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 7b29ddda..cdcf3a45 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -748,3 +748,111 @@ describe('NoteList — trash view', () => { expect(screen.getByText('Trash is empty')).toBeInTheDocument() }) }) + +// --- Virtual list performance tests --- + +describe('NoteList — virtual list with large datasets', () => { + const makeEntry = (i: number, overrides?: Partial): VaultEntry => ({ + path: `/vault/note/note-${i}.md`, + filename: `note-${i}.md`, + title: `Note ${i}`, + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: 1700000000 - i * 60, + createdAt: null, + fileSize: 500, + snippet: `Content of note ${i}`, + relationships: {}, + icon: null, + color: null, + order: null, + ...overrides, + }) + + it('renders 9000 entries without crashing', { timeout: 15000 }, () => { + const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i)) + const { container } = render( + + ) + // Virtuoso mock renders all items; the real component only renders visible ones + expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument() + }) + + it('renders items from a large dataset via Virtuoso', () => { + const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i)) + render( + + ) + expect(screen.getByText('Note 0')).toBeInTheDocument() + expect(screen.getByText('Note 499')).toBeInTheDocument() + }) + + it('search filters large dataset correctly', () => { + const entries = [ + makeEntry(0, { title: 'Alpha Strategy' }), + ...Array.from({ length: 998 }, (_, i) => makeEntry(i + 1, { title: `Filler Note ${i + 1}` })), + makeEntry(999, { title: 'Beta Strategy' }), + ] + render( + + ) + fireEvent.click(screen.getByTitle('Search notes')) + fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } }) + expect(screen.getByText('Alpha Strategy')).toBeInTheDocument() + expect(screen.getByText('Beta Strategy')).toBeInTheDocument() + expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument() + }) + + it('sorting works with large dataset', () => { + const entries = [ + makeEntry(0, { title: 'Zebra', modifiedAt: 1000 }), + makeEntry(1, { title: 'Alpha', modifiedAt: 3000 }), + ...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })), + ] + render( + + ) + // Default sort is modified desc — Alpha (3000) should come first + const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0] + expect(firstTitle.textContent).toBe('Alpha') + }) + + it('section group filter works with large mixed-type dataset', () => { + const entries = [ + ...Array.from({ length: 100 }, (_, i) => makeEntry(i, { isA: 'Project', title: `Project ${i}` })), + ...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })), + ] + render( + + ) + expect(screen.getByText('Project 0')).toBeInTheDocument() + expect(screen.queryByText('Note 0')).not.toBeInTheDocument() + }) + + it('selection highlighting works in virtualized list', () => { + const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i)) + const selected = entries[5] + render( + + ) + expect(screen.getByText('Note 5')).toBeInTheDocument() + }) + + it('click handler works on virtualized items', () => { + noopReplace.mockClear() + const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i)) + render( + + ) + fireEvent.click(screen.getByText('Note 50')) + expect(noopReplace).toHaveBeenCalledWith(entries[50]) + }) +}) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 4ed2c785..c1d8e161 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,4 +1,5 @@ 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 { @@ -133,6 +134,18 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl ) } +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 @@ -140,15 +153,27 @@ function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, quer 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 ( -
- {typeDocument && } - - {searched.length === 0 - ? - : searched.map((entry) => renderItem(entry)) - } -
+ : undefined, + }} + itemContent={(_index, entry) => renderItem(entry)} + /> ) } diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 27097cca..5f3dd467 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -1550,6 +1550,62 @@ const MOCK_ENTRIES: VaultEntry[] = [ }, ] +// --- Bulk entry generator for large vault testing --- + +const BULK_TYPES = ['Note', 'Project', 'Experiment', 'Responsibility', 'Procedure', 'Person', 'Event', 'Essay', 'Topic'] +const BULK_ADJECTIVES = ['Quick', 'Advanced', 'Daily', 'Weekly', 'Annual', 'Draft', 'Final', 'Revised', 'Archived', 'New'] +const BULK_NOUNS = ['Meeting', 'Strategy', 'Review', 'Plan', 'Analysis', 'Summary', 'Report', 'Guide', 'Checklist', 'Template', 'Framework', 'Workflow', 'Retrospective', 'Brainstorm', 'Proposal'] +const BULK_SNIPPETS = [ + 'Key findings from the latest analysis session.', + 'Notes on process improvements and next steps.', + 'Summary of decisions made during the review.', + 'Action items and follow-ups from discussion.', + 'Draft outline for upcoming deliverable.', + 'Reference material for the ongoing initiative.', + 'Tracking progress on quarterly objectives.', + 'Comparison of different approaches considered.', +] + +function generateBulkEntries(count: number): VaultEntry[] { + const now = Date.now() / 1000 + const entries: VaultEntry[] = [] + for (let i = 0; i < count; i++) { + const type = BULK_TYPES[i % BULK_TYPES.length] + const adj = BULK_ADJECTIVES[i % BULK_ADJECTIVES.length] + const noun = BULK_NOUNS[i % BULK_NOUNS.length] + const title = `${adj} ${noun} ${i + 1}` + const slug = title.toLowerCase().replace(/\s+/g, '-') + const folder = type.toLowerCase() + entries.push({ + path: `/Users/luca/Laputa/${folder}/${slug}.md`, + filename: `${slug}.md`, + title, + isA: type, + aliases: [], + belongsTo: [], + relatedTo: [], + status: i % 4 === 0 ? 'Active' : i % 4 === 1 ? 'Paused' : i % 4 === 2 ? 'Done' : null, + owner: i % 3 === 0 ? 'Luca Rossi' : null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: now - i * 600, + createdAt: now - 86400 * 90 - i * 3600, + fileSize: 500 + (i % 2000), + snippet: BULK_SNIPPETS[i % BULK_SNIPPETS.length], + relationships: {}, + icon: null, + color: null, + order: null, + }) + } + return entries +} + +// Append 9000 generated entries for realistic large-vault testing +MOCK_ENTRIES.push(...generateBulkEntries(9000)) + function mockFileHistory(path: string): GitCommit[] { const filename = path.split('/').pop()?.replace('.md', '') ?? 'unknown' const now = Math.floor(Date.now() / 1000) diff --git a/src/test/setup.ts b/src/test/setup.ts index a9d0dd31..966dc7cc 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,40 @@ import '@testing-library/jest-dom/vitest' +import { vi } from 'vitest' +import { createElement, type ReactNode, type ComponentType } from 'react' + +// Mock react-virtuoso: JSDOM has no real viewport, so render all items directly +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ data, itemContent, components }: { + data?: unknown[] + itemContent?: (index: number, item: unknown) => ReactNode + components?: { Header?: ComponentType } + }) => { + const Header = components?.Header + return createElement('div', { 'data-testid': 'virtuoso-mock' }, + Header ? createElement(Header) : null, + data?.map((item: unknown, index: number) => + createElement('div', { key: index }, itemContent?.(index, item)) + ) + ) + }, + GroupedVirtuoso: ({ groupCounts, groupContent, itemContent }: { + groupCounts: number[] + groupContent: (index: number) => ReactNode + itemContent: (index: number, groupIndex: number) => ReactNode + }) => { + let globalIndex = 0 + return createElement('div', { 'data-testid': 'grouped-virtuoso-mock' }, + groupCounts?.map((count: number, groupIndex: number) => { + const items = [] + for (let i = 0; i < count; i++) { + items.push(createElement('div', { key: globalIndex }, itemContent(globalIndex, groupIndex))) + globalIndex++ + } + return createElement('div', { key: `group-${groupIndex}` }, + groupContent(groupIndex), + ...items + ) + }) + ) + }, +}))