sotashimozono_obsidian-remo.../plugin/tests/LazyFolderLoader.test.ts
Souta 4ae3170cc0 feat(vault): lazy per-folder loading — deepen the remote tree on File-Explorer expand
Dogfooding: even chunked (#448/beta.15), a deep dir-heavy vault (work/Vault with
tens of thousands of dirs nested deep) is too heavy to walk + materialise in
full at connect. Load ONE folder level at a time instead.

A real-Obsidian spike settled the two runtime unknowns: (U2) a childless
TFolder still renders an expand arrow (mod-collapsible), so an unloaded folder
is openable; (U1) File Explorer renders from the in-memory model and does NOT
call adapter.list on expand, so deepen-on-expand is driven by a DOM
.nav-folder-title click hook (the element carries the folder's data-path).

- BulkWalker.walk gains a `recursive` param; recursive:false returns just a
  folder's immediate children (fs.walk / one-level adapter.list), honouring
  walkIgnoreDirs per level.
- LazyFolderLoader: idempotent, deduped per-folder deepen (walk one level ->
  buildChunked). Unit tests cover recursive-false walk, idempotency,
  concurrent-dedup, markLoaded, retry-on-failure, reset.
- populateVaultFromRemote: with `lazyFolderLoad` (default true) it walks only
  the root level, wires the loader, marks root loaded, and installs a
  capture-phase .nav-folder-title click hook that deepens on first expand.
  `lazyFolderLoad:false` restores the full eager walk. Loader dropped on
  disconnect.

ignore (reduce N) and lazy (limit depth) compose; the live fs.changed watch
still works against a partial tree via ensureParents. Graph/search see only
loaded branches for now (accepted; revisit later). tsc/lint/vitest green
(1202). Ships as beta.16.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:25:59 +09:00

83 lines
3.4 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { LazyFolderLoader } from '../src/vault/LazyFolderLoader';
import type { BulkWalker } from '../src/vault/BulkWalker';
import type { VaultModelBuilder } from '../src/vault/VaultModelBuilder';
function fakeWalk(path: string) {
return {
entries: [{ path: `${path}/child.md`, isDirectory: false, ctime: 0, mtime: 0, size: 0 }],
source: 'rpc-walk' as const,
truncated: false,
walkMs: 1,
pages: 1,
fastPathError: null,
};
}
function makeBuilder(): VaultModelBuilder & { buildChunked: ReturnType<typeof vi.fn> } {
return {
buildChunked: vi.fn(async (entries: readonly unknown[]) => ({
filesAdded: entries.length, foldersAdded: 0, skipped: 0, errors: [],
})),
} as unknown as VaultModelBuilder & { buildChunked: ReturnType<typeof vi.fn> };
}
describe('LazyFolderLoader', () => {
it('loadFolder walks ONE level (recursive=false) and builds the children', async () => {
const walk = vi.fn(async (path: string, _recursive: boolean) => fakeWalk(path));
const builder = makeBuilder();
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => builder);
await loader.loadFolder('work/sub');
expect(walk).toHaveBeenCalledExactlyOnceWith('work/sub', false);
expect(builder.buildChunked).toHaveBeenCalledOnce();
expect(loader.isLoaded('work/sub')).toBe(true);
});
it('is idempotent — a second load of the same folder does not re-walk', async () => {
const walk = vi.fn(async (path: string) => fakeWalk(path));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
await loader.loadFolder('a');
await loader.loadFolder('a');
expect(walk).toHaveBeenCalledTimes(1);
});
it('dedupes concurrent loads of the same folder into one walk', async () => {
const walk = vi.fn(async (path: string) => fakeWalk(path));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
await Promise.all([loader.loadFolder('b'), loader.loadFolder('b'), loader.loadFolder('b')]);
expect(walk).toHaveBeenCalledTimes(1);
});
it('markLoaded skips the walk entirely', async () => {
const walk = vi.fn(async (path: string) => fakeWalk(path));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
loader.markLoaded('c');
await loader.loadFolder('c');
expect(walk).not.toHaveBeenCalled();
});
it('a failed walk leaves the folder unloaded so a later expand retries', async () => {
const walk = vi.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(fakeWalk('x'));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
await loader.loadFolder('x');
expect(loader.isLoaded('x')).toBe(false); // failed → not marked loaded
await loader.loadFolder('x'); // retry succeeds
expect(walk).toHaveBeenCalledTimes(2);
expect(loader.isLoaded('x')).toBe(true);
});
it('reset clears loaded state', async () => {
const walk = vi.fn(async (path: string) => fakeWalk(path));
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
await loader.loadFolder('a');
expect(loader.isLoaded('a')).toBe(true);
loader.reset();
expect(loader.isLoaded('a')).toBe(false);
});
});