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:
21
.claude-done
21
.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 <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
|
||||
|
||||
273
design/performance-note-list.pen
Normal file
273
design/performance-note-list.pen
Normal 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": {}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user