From 093f1bc9dc65daaf14f7a49f2c17d4ade35003cb Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 31 Mar 2026 11:19:31 +0200 Subject: [PATCH] test: add folder tree and filtering tests; docs: ADR-0033 Add FolderTree component tests (render, expand, collapse, select, highlight) and folder filtering tests in noteListHelpers (path matching, sibling exclusion, archive/trash filtering). ADR-0033 documents the decision to scan all subdirectories and expose folder tree navigation, superseding ADR-0006's scanning constraint. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...0033-subfolder-scanning-and-folder-tree.md | 33 +++++++++ docs/adr/README.md | 1 + src/components/FolderTree.test.tsx | 70 +++++++++++++++++++ src/utils/noteListHelpers.test.ts | 41 ++++++++++- 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0033-subfolder-scanning-and-folder-tree.md create mode 100644 src/components/FolderTree.test.tsx diff --git a/docs/adr/0033-subfolder-scanning-and-folder-tree.md b/docs/adr/0033-subfolder-scanning-and-folder-tree.md new file mode 100644 index 00000000..5c901831 --- /dev/null +++ b/docs/adr/0033-subfolder-scanning-and-folder-tree.md @@ -0,0 +1,33 @@ +--- +type: ADR +id: "0033" +title: "Subfolder scanning and folder tree navigation" +status: active +date: 2026-03-31 +--- + +## Context + +Supersedes the scanning constraint in [ADR-0006](0006-flat-vault-structure.md) which limited vault indexing to root-level `.md` files plus protected folders (`attachments/`, `assets/`). + +Users with folder-based workflows (PARA, Zettelkasten with folders, project directories) could not see or filter notes by directory. The vault scanner silently ignored all subdirectory `.md` files, making Laputa unsuitable for vaults with any folder structure. + +## Decision + +**Extend the Rust vault scanner to index `.md` files in all visible subdirectories, and expose the vault's folder tree via a new `list_vault_folders` Tauri command so the sidebar can render a collapsible FOLDERS section.** + +Hidden directories (names starting with `.`, plus `.git` and `.laputa`) are excluded from both scanning and the folder tree. + +## Options considered + +- **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache. +- **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes. +- **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories. + +## Consequences + +- All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed. +- The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching. +- `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it. +- ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root. +- Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected). diff --git a/docs/adr/README.md b/docs/adr/README.md index d2bda2aa..549ed275 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -88,3 +88,4 @@ proposed → active → superseded | [0030](0030-rust-commands-module-split.md) | Rust commands/ module split by domain | active | | [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active | | [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active | +| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active | diff --git a/src/components/FolderTree.test.tsx b/src/components/FolderTree.test.tsx new file mode 100644 index 00000000..434788d7 --- /dev/null +++ b/src/components/FolderTree.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { FolderTree } from './FolderTree' +import type { FolderNode, SidebarSelection } from '../types' + +const mockFolders: FolderNode[] = [ + { + name: 'projects', + path: 'projects', + children: [ + { name: 'laputa', path: 'projects/laputa', children: [] }, + { name: 'portfolio', path: 'projects/portfolio', children: [] }, + ], + }, + { name: 'areas', path: 'areas', children: [] }, + { name: 'journal', path: 'journal', children: [] }, +] + +const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' } + +describe('FolderTree', () => { + it('renders nothing when folders is empty', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('renders FOLDERS header and top-level folders', () => { + render() + expect(screen.getByText('FOLDERS')).toBeInTheDocument() + expect(screen.getByText('projects')).toBeInTheDocument() + expect(screen.getByText('areas')).toBeInTheDocument() + expect(screen.getByText('journal')).toBeInTheDocument() + }) + + it('does not show children initially', () => { + render() + expect(screen.queryByText('laputa')).not.toBeInTheDocument() + }) + + it('calls onSelect with folder kind when clicking a folder', () => { + const onSelect = vi.fn() + render() + fireEvent.click(screen.getByText('projects')) + expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' }) + }) + + it('expands children when clicking a folder with children', () => { + const onSelect = vi.fn() + render() + fireEvent.click(screen.getByText('projects')) + expect(screen.getByText('laputa')).toBeInTheDocument() + expect(screen.getByText('portfolio')).toBeInTheDocument() + }) + + it('collapses section when clicking FOLDERS header', () => { + render() + expect(screen.getByText('projects')).toBeInTheDocument() + fireEvent.click(screen.getByText('FOLDERS')) + expect(screen.queryByText('projects')).not.toBeInTheDocument() + }) + + it('highlights selected folder', () => { + const sel: SidebarSelection = { kind: 'folder', path: 'areas' } + render() + const btn = screen.getByText('areas').closest('button')! + expect(btn.className).toContain('text-primary') + }) +}) diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index 7733c969..2a22388d 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries } from './noteListHelpers' +import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries, filterEntries } from './noteListHelpers' import type { VaultEntry } from '../types' function makeEntry(overrides: Partial = {}): VaultEntry { @@ -695,3 +695,42 @@ describe('filterInboxEntries', () => { expect(result).toEqual([]) }) }) + +describe('filterEntries — folder selection', () => { + const entries = [ + makeEntry({ path: '/vault/projects/laputa/note1.md', title: 'Note 1' }), + makeEntry({ path: '/vault/projects/laputa/note2.md', title: 'Note 2' }), + makeEntry({ path: '/vault/projects/portfolio/site.md', title: 'Site' }), + makeEntry({ path: '/vault/areas/health.md', title: 'Health' }), + makeEntry({ path: '/vault/root-note.md', title: 'Root' }), + ] + + it('filters entries by folder path', () => { + const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' }) + expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2']) + }) + + it('does not include notes from sibling folders', () => { + const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' }) + expect(result.find(e => e.title === 'Site')).toBeUndefined() + }) + + it('filters by parent folder (non-recursive — direct children only)', () => { + const result = filterEntries(entries, { kind: 'folder', path: 'areas' }) + expect(result.map(e => e.title)).toEqual(['Health']) + }) + + it('returns empty for root when entries are in subfolders', () => { + const result = filterEntries(entries, { kind: 'folder', path: 'nonexistent' }) + expect(result).toEqual([]) + }) + + it('excludes archived and trashed entries by default', () => { + const withArchived = [ + ...entries, + makeEntry({ path: '/vault/projects/laputa/archived.md', title: 'Archived', archived: true }), + ] + const result = filterEntries(withArchived, { kind: 'folder', path: 'projects/laputa' }) + expect(result.find(e => e.title === 'Archived')).toBeUndefined() + }) +})