Merge pull request #27 from refactoringhq/task/performance-note-list

perf: virtual list rendering for NoteList (react-virtuoso, 9000+ notes)
This commit is contained in:
Luca Rossi
2026-02-23 22:06:05 +01:00
committed by GitHub
7 changed files with 528 additions and 12 deletions

View File

@@ -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 <Virtuoso> 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

View File

@@ -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": {}
}

View File

@@ -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.

View File

@@ -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>): 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(
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByText('Note 50'))
expect(noopReplace).toHaveBeenCalledWith(entries[50])
})
})

View File

@@ -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<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
return (
<>
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
</>
)
}
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<string, VaultEntry>; 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 (
<div className="h-full overflow-y-auto">
{hasHeader && <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<EmptyMessage text={emptyText} />
</div>
)
}
return (
<div className="h-full overflow-y-auto">
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
{searched.length === 0
? <EmptyMessage text={emptyText} />
: searched.map((entry) => renderItem(entry))
}
</div>
<Virtuoso
style={{ height: '100%' }}
data={searched}
overscan={200}
components={{
Header: hasHeader ? () => <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} /> : undefined,
}}
itemContent={(_index, entry) => renderItem(entry)}
/>
)
}

View File

@@ -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)

View File

@@ -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
)
})
)
},
}))