Merge pull request #468 from sotashimozono/feat/background-full-index

feat(vault): background full index — graph, search and links see the whole vault again
This commit is contained in:
sotashimozono 2026-07-14 22:20:36 +09:00 committed by GitHub
commit 5b0442cb41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 751 additions and 5 deletions

View file

@ -1,7 +1,7 @@
{
"id": "remote-ssh",
"name": "Remote SSH",
"version": "1.1.8-beta.2",
"version": "1.1.8-beta.4",
"minAppVersion": "1.5.0",
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
"author": "souta shimozono",

View file

@ -1,7 +1,7 @@
{
"id": "remote-ssh",
"name": "Remote SSH",
"version": "1.1.8-beta.2",
"version": "1.1.8-beta.4",
"minAppVersion": "1.5.0",
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
"author": "souta shimozono",

View file

@ -1,12 +1,12 @@
{
"name": "obsidian-remote-ssh",
"version": "1.1.8-beta.2",
"version": "1.1.8-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-remote-ssh",
"version": "1.1.8-beta.2",
"version": "1.1.8-beta.4",
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-remote-ssh",
"version": "1.1.8-beta.2",
"version": "1.1.8-beta.4",
"description": "VS Code Remote SSH-like experience for Obsidian",
"main": "main.js",
"scripts": {

View file

@ -25,6 +25,7 @@ import { VaultModelBuilder } from './vault/VaultModelBuilder';
import { FsChangeListener } from './vault/FsChangeListener';
import { BulkWalker } from './vault/BulkWalker';
import { LazyFolderLoader } from './vault/LazyFolderLoader';
import { BackgroundIndexer, type IndexProgress } from './vault/BackgroundIndexer';
import { RenameLeafFollower } from './vault/RenameLeafFollower';
import { ObsidianRegistry } from './shadow/ObsidianRegistry';
import { ShadowVaultBootstrap } from './shadow/ShadowVaultBootstrap';
@ -868,6 +869,12 @@ export default class RemoteSshPlugin extends Plugin {
// Drop lazy-load state — the walker it captured is now disconnected. A
// stray File-Explorer click after this finds a null loader and no-ops.
this.lazyLoader = null;
// Same for the background full-index pass: its walker rides the transport
// we're about to tear down, so stop it before the socket goes. `cancel()`
// makes it bail at its next checkpoint (one folder / one depth level away),
// and dropping the reference makes its late progress emits no-ops.
this.backgroundIndexer?.cancel();
this.backgroundIndexer = null;
await this.conn.disconnectTransport();
this.setState(SyncState.IDLE);
if (this.settings.activeProfileId !== null) {
@ -913,6 +920,66 @@ export default class RemoteSshPlugin extends Plugin {
private lazyLoader: LazyFolderLoader | null = null;
private lazyExpandHookInstalled = false;
/** Background full-tree index; null until a lazy connect, dropped on disconnect. */
private backgroundIndexer: BackgroundIndexer | null = null;
/**
* Start (or restart) the background full-index pass that completes the vault
* model behind the lazy root-level populate.
*
* Fire-and-forget by design: connect returns immediately and the indexer
* trickles the rest of the tree into `vault.fileMap`, yielding to the renderer
* between units. Any previous pass (e.g. from the connect before a reconnect)
* is cancelled first, so only one indexer is ever live and a stale one can't
* write progress over the new one's.
*/
private startBackgroundIndex(): void {
this.backgroundIndexer?.cancel();
const indexer: BackgroundIndexer = new BackgroundIndexer({
makeWalker: () => this.makeWalker(),
makeBuilder: () => new VaultModelBuilder(this.app.vault, { TFile, TFolder }),
// Folders the indexer has fully materialised don't need re-walking when the
// user later expands them in File Explorer.
markLoaded: (path) => this.lazyLoader?.markLoaded(path),
onProgress: (p) => this.onIndexProgress(indexer, p),
});
this.backgroundIndexer = indexer;
void indexer.start();
}
/**
* Surface indexing honestly. Until the pass completes, search / graph /
* backlinks really ARE incomplete, so say so in the status bar rather than
* letting a vault that has registered 12 of 30 000 files look "ready".
*
* Non-nagging: status-bar text while it runs (no modal, no toast spam), and a
* single Notice when it finishes. A cancelled pass says nothing the user
* disconnected; they don't need a report.
*/
private onIndexProgress(indexer: BackgroundIndexer, p: IndexProgress): void {
// A pass superseded by a reconnect, or one still unwinding after disconnect,
// must not paint over the live session's status bar.
if (this.backgroundIndexer !== indexer) return;
if (this.state !== SyncState.CONNECTED) return;
if (!p.done) {
this.statusBar?.update(
SyncState.CONNECTED,
`Remote SSH: Indexing… ${p.files} files`,
);
return;
}
this.statusBar?.update(SyncState.CONNECTED);
if (p.cancelled) return;
// Nothing added means the connect populate had already registered the whole
// vault (a flat, root-only tree) — there was no gap, so there's nothing to
// announce. Announcing "0 files indexed" would be noise on every connect.
if (p.files + p.folders === 0) return;
new Notice(
`Remote SSH: vault index complete — ${p.files} files, ${p.folders} folders. ` +
'Search, graph and links now cover the whole vault.',
);
}
/** A fresh BulkWalker bound to the current session's transport + ignore list. */
private makeWalker(): BulkWalker {
return new BulkWalker({
@ -1012,7 +1079,16 @@ export default class RemoteSshPlugin extends Plugin {
);
this.lazyLoader.markLoaded('');
this.installLazyExpandHook();
// The root level is on screen and connect is done — now index the REST of
// the tree in the background. Without this, everything below depth 1 stays
// out of `vault.fileMap` until the user happens to click the folder open,
// and Obsidian resolves links/embeds/search only against `fileMap` — so a
// link into an unexpanded subfolder silently fails to resolve. Deliberately
// NOT awaited: connect must stay fast, and the indexer yields between units.
this.startBackgroundIndex();
}
// `lazyFolderLoad: false` needs no background pass — the walk above was
// already the full recursive tree, so `fileMap` is complete on return.
const summary =
`${result.filesAdded}f + ${result.foldersAdded}d built, ` +

View file

@ -0,0 +1,286 @@
import type { BulkWalker } from './BulkWalker';
import type { RemoteEntry, VaultModelBuilder } from './VaultModelBuilder';
import { logger } from '../util/logger';
import { errorMessage } from '../util/errorMessage';
/** Snapshot handed to `onProgress`. Counts are cumulative for one indexer run. */
export interface IndexProgress {
/** Files newly inserted into `vault.fileMap` by THIS indexer. */
files: number;
/** Folders newly inserted into `vault.fileMap` by THIS indexer. */
folders: number;
/** True on the final emit (whether the pass completed, failed, or was cancelled). */
done: boolean;
/** True on the final emit when `cancel()` cut the pass short. */
cancelled: boolean;
}
export interface BackgroundIndexerDeps {
/** Fresh walker per walk — mirrors LazyFolderLoader and the connect populate. */
makeWalker: () => BulkWalker;
/** Fresh (stateless) builder per build. */
makeBuilder: () => VaultModelBuilder;
/**
* Called with each folder path whose immediate children are now fully
* materialised, so `LazyFolderLoader` skips re-walking it on a later
* File-Explorer expand click.
*/
markLoaded: (path: string) => void;
/** Progress pump (status bar / notice). Always called at least once, with `done`. */
onProgress?: (p: IndexProgress) => void;
/**
* Entries per `buildChunked` chunk. Deliberately SMALLER than the connect-time
* populate's 500 this runs while the user is working, so each synchronous
* burst must stay short.
*/
chunkSize?: number;
/**
* Yield between units of work. Defaults to `requestIdleCallback` (falling back
* to a 0 ms timer). Injectable so tests can observe that we actually yield.
*/
yieldFn?: () => Promise<void>;
}
/**
* Indexes the WHOLE remote tree into `vault.fileMap`, in the background, after
* the connect-time populate has already rendered the root level.
*
* ## Why this exists
*
* The lazy connect populate (`main.populateVaultFromRemote` with
* `lazyFolderLoad`, the default) walks only the root's IMMEDIATE children, and
* `LazyFolderLoader` deepens a folder only when the user CLICKS it open in File
* Explorer. That keeps connect fast on a deep, dir-heavy vault but everything
* below depth 1 is then *absent from `vault.fileMap` entirely*, and Obsidian's
* `metadataCache` resolves links only against `fileMap`. So a `[[link]]` into a
* never-expanded subfolder does not resolve, an image embedded from one does not
* render, and global search / quick switcher / graph / backlinks silently see a
* partial vault. Registering the file is enough to fix all of those: once a path
* lands in `fileMap`, Obsidian re-resolves the links pointing at it on its own.
*
* ## Shape
*
* - **Fast path** (daemon advertises `fs.walk`): ONE recursive walk. It is
* paginated server-side (one RPC per page), so the walk itself is cheap; the
* cost that used to freeze Obsidian was *materialising* the entries, which
* `buildChunked` already solves. Entries are fed depth-by-depth so a parent
* folder is always inserted in an earlier `buildChunked` call than its
* children, and so cancellation gets frequent, consistent cut points.
* - **Fallback path** (SFTP / no daemon): breadth-first, ONE FOLDER AT A TIME.
* The fallback costs one `adapter.list` round-trip per directory, so a
* dir-heavy tree must trickle rather than stall walk a folder, build it,
* mark it loaded, enqueue its subfolders, yield.
*
* Both paths are safe to race the click-driven `LazyFolderLoader` and the live
* `FsChangeListener`: `VaultModelBuilder`'s inserts skip a path that is already
* present, so a double-insert is a counted no-op, never a duplicate.
*
* File CONTENT is never read only the tree. (Obsidian's own metadataCache
* reads the files it cares about; that traffic is its call, not ours.)
*/
export class BackgroundIndexer {
private cancelled = false;
private running = false;
private run: Promise<void> | null = null;
private files = 0;
private folders = 0;
private lastLoggedAt = 0;
/** Emit a `logger.info` progress line every this many newly-inserted entries. */
private static readonly LOG_EVERY = 500;
constructor(private readonly deps: BackgroundIndexerDeps) {}
get isRunning(): boolean { return this.running; }
get isCancelled(): boolean { return this.cancelled; }
/** Entries this indexer has inserted so far. */
get inserted(): { files: number; folders: number } {
return { files: this.files, folders: this.folders };
}
/**
* Kick off the pass. Returns a promise that settles when indexing finishes (or
* is cancelled) callers normally fire-and-forget it. Calling `start()` twice
* returns the same in-flight run; a cancelled indexer is not restartable (build
* a fresh one, as `main` does on reconnect).
*/
start(): Promise<void> {
if (this.run) return this.run;
this.running = true;
this.run = this.doRun().finally(() => { this.running = false; });
return this.run;
}
/**
* Stop as soon as the current unit of work finishes. Checked before every walk,
* before every build, and between units, so an in-flight pass drops out within
* one folder / one depth level.
*
* Folders whose children did not all land are left UNMARKED, so
* `LazyFolderLoader` still deepens them on expand: a cancelled index degrades
* back to exactly the lazy behaviour, never to a folder that claims to be
* loaded but is empty.
*/
cancel(): void {
this.cancelled = true;
}
// ─── internals ──────────────────────────────────────────────────────────
private async doRun(): Promise<void> {
const start = Date.now();
try {
const walker = this.deps.makeWalker();
if (walker.hasFastPath()) {
await this.indexViaFullWalk(walker);
} else {
await this.indexBreadthFirst();
}
} catch (e) {
// A background index that dies must never take the session with it — the
// vault just stays lazily loaded (i.e. the pre-existing behaviour).
logger.warn(`BackgroundIndexer: aborted (${errorMessage(e)}); vault stays lazily loaded`);
} finally {
logger.info(
`BackgroundIndexer: ${this.cancelled ? 'cancelled' : 'complete'}` +
`${this.files}f + ${this.folders}d indexed in ${Date.now() - start}ms`,
);
this.emit(true);
}
}
/**
* Daemon fast path: one paginated recursive `fs.walk`, then materialise the
* result depth-by-depth. Folders are marked loaded only once the whole tree is
* in, because this walk is all-or-nothing marking mid-pass could mark a
* folder whose children are still queued behind a later depth.
*/
private async indexViaFullWalk(walker: BulkWalker): Promise<void> {
const walk = await walker.walk('', true);
if (this.cancelled) return;
logger.info(
`BackgroundIndexer: full walk — ${walk.entries.length} entries ` +
`(${walk.hiddenCount} hidden) in ${walk.walkMs}ms (pages=${walk.pages})`,
);
const byDepth = new Map<number, RemoteEntry[]>();
for (const e of walk.entries) {
const bucket = byDepth.get(depthOf(e.path));
if (bucket) bucket.push(e);
else byDepth.set(depthOf(e.path), [e]);
}
const builder = this.deps.makeBuilder();
for (const depth of [...byDepth.keys()].sort((a, b) => a - b)) {
if (this.cancelled) return;
// buildChunked sorts folders-before-files within the call and yields
// between chunks, so a same-depth file always finds its (same-call,
// earlier) parent — and a deeper file finds its parent from the previous
// depth's call.
const result = await builder.buildChunked(byDepth.get(depth)!, this.chunkSize);
this.files += result.filesAdded;
this.folders += result.foldersAdded;
this.emit(false);
await this.yieldNow();
}
if (this.cancelled) return;
for (const e of walk.entries) {
if (e.isDirectory) this.deps.markLoaded(e.path);
}
}
/**
* SFTP / no-daemon fallback: breadth-first, one folder per iteration. Each
* folder costs one `adapter.list` round-trip, so we yield after every one and
* mark it loaded as soon as its children are in a dir-heavy tree trickles
* into the model instead of stalling inside one unbounded traversal.
*
* Seeded with the root: re-walking it costs one list, and every entry it
* returns is already in `fileMap` from the connect populate, so that first
* rebuild is a pure (counted) skip.
*/
private async indexBreadthFirst(): Promise<void> {
const queue: string[] = [''];
const seen = new Set<string>(queue);
while (queue.length > 0) {
if (this.cancelled) return;
const folder = queue.shift()!;
let entries: RemoteEntry[];
try {
entries = (await this.deps.makeWalker().walk(folder, false)).entries;
} catch (e) {
// One unreadable folder (permissions, vanished mid-walk) must not sink
// the whole index. Left unmarked, so an expand click can still retry it.
logger.warn(`BackgroundIndexer: walk "${folder}" failed (${errorMessage(e)}); skipping`);
continue;
}
if (this.cancelled) return;
const result = await this.deps.makeBuilder().buildChunked(entries, this.chunkSize);
this.files += result.filesAdded;
this.folders += result.foldersAdded;
this.deps.markLoaded(folder);
for (const e of entries) {
if (!e.isDirectory || seen.has(e.path)) continue;
seen.add(e.path);
queue.push(e.path);
}
this.emit(false);
await this.yieldNow();
}
}
private get chunkSize(): number {
return this.deps.chunkSize ?? 100;
}
private emit(done: boolean): void {
const total = this.files + this.folders;
if (!done && total - this.lastLoggedAt >= BackgroundIndexer.LOG_EVERY) {
this.lastLoggedAt = total;
logger.info(`BackgroundIndexer: ${this.files}f + ${this.folders}d indexed so far…`);
}
this.deps.onProgress?.({
files: this.files,
folders: this.folders,
done,
cancelled: this.cancelled,
});
}
private yieldNow(): Promise<void> {
return this.deps.yieldFn ? this.deps.yieldFn() : idleYield();
}
}
// ─── helpers ──────────────────────────────────────────────────────────────
interface IdleWindow {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
}
/**
* Hand the main thread back between units of work. `requestIdleCallback` runs us
* only when the renderer has slack (so typing and scrolling always win), with a
* `timeout` so a permanently-busy renderer can't starve the index outright.
* Electron/Obsidian ships it, but it isn't in the DOM lib's guaranteed surface
* (and jsdom omits it), so we degrade to the same 0 ms macrotask yield
* `VaultModelBuilder.buildChunked` uses.
*/
function idleYield(): Promise<void> {
const ric = (window as unknown as IdleWindow).requestIdleCallback;
if (typeof ric === 'function') {
return new Promise<void>((resolve) => { ric(() => resolve(), { timeout: 500 }); });
}
return new Promise<void>((resolve) => { window.setTimeout(resolve, 0); });
}
function depthOf(path: string): number {
let count = 0;
for (let i = 0; i < path.length; i++) {
if (path[i] === '/') count++;
}
return count;
}

View file

@ -166,6 +166,20 @@ export class BulkWalker {
};
}
/**
* True when the daemon's paginated `fs.walk` is available, i.e. a full
* recursive `walk('', true)` costs one RPC per 50 000-entry page rather than
* one `adapter.list` round-trip per directory.
*
* Callers that must CHOOSE a traversal strategy up-front need this, because
* `walk()` only reports which path it took (`BulkWalkResult.source`) after the
* expensive part is already done. `BackgroundIndexer` uses it to pick between
* one recursive walk and a yielding folder-at-a-time BFS.
*/
hasFastPath(): boolean {
return this.canUseFastPath();
}
/**
* Drop dot-prefixed entries so hidden files/dirs never reach the File
* Explorer matching Obsidian's own default of hiding dot-names. An entry

View file

@ -0,0 +1,370 @@
import { describe, it, expect, vi } from 'vitest';
import { BackgroundIndexer, type IndexProgress } from '../src/vault/BackgroundIndexer';
import type { BulkWalker } from '../src/vault/BulkWalker';
import type { RemoteEntry, VaultModelBuilder } from '../src/vault/VaultModelBuilder';
// ─── fixtures ───────────────────────────────────────────────────────────────
function file(path: string): RemoteEntry {
return { path, isDirectory: false, ctime: 0, mtime: 0, size: 0 };
}
function dir(path: string): RemoteEntry {
return { path, isDirectory: true, ctime: 0, mtime: 0, size: 0 };
}
function walkResult(entries: RemoteEntry[]) {
return {
entries,
source: 'rpc-walk' as const,
truncated: false,
walkMs: 1,
pages: 1,
fastPathError: null,
hiddenCount: 0,
};
}
/**
* A tree three levels deep. Only `root.md` and `work/` sit at depth 0 i.e.
* everything else is exactly what the lazy connect populate MISSES, and so
* exactly what the background pass must register.
*/
const TREE: RemoteEntry[] = [
file('root.md'),
dir('work'),
file('work/note.md'),
dir('work/sub'),
file('work/sub/deep.md'),
file('work/sub/img.png'),
];
/** Immediate children of `folder` within TREE — what a one-level walk returns. */
function childrenOf(folder: string): RemoteEntry[] {
return TREE.filter((e) => {
const i = e.path.lastIndexOf('/');
return (i < 0 ? '' : e.path.slice(0, i)) === folder;
});
}
/**
* A walker stub. `hasFastPath` selects which of the indexer's two traversal
* strategies runs: the daemon's one-shot paginated `fs.walk`, or the SFTP
* fallback's folder-at-a-time BFS.
*/
function makeWalker(opts: {
fastPath: boolean;
walk?: (path: string, recursive: boolean) => Promise<ReturnType<typeof walkResult>>;
}) {
const walk = vi.fn(
opts.walk ??
((path: string, recursive: boolean) =>
Promise.resolve(walkResult(recursive ? TREE : childrenOf(path)))),
);
const walker = { hasFastPath: () => opts.fastPath, walk } as unknown as BulkWalker;
return { walker, walk };
}
/**
* A builder stub modelling the ONE property the indexer leans on for safety:
* inserts are idempotent an already-present path is counted as `skipped`, not
* re-added. `present` seeds the paths the connect populate already registered.
*/
function makeBuilder(present: Iterable<string> = []) {
const fileMap = new Set<string>(present);
const buildChunked = vi.fn((entries: readonly RemoteEntry[], _chunkSize?: number) => {
let filesAdded = 0, foldersAdded = 0, skipped = 0;
for (const e of entries) {
if (fileMap.has(e.path)) { skipped++; continue; }
fileMap.add(e.path);
if (e.isDirectory) foldersAdded++;
else filesAdded++;
}
return Promise.resolve({ filesAdded, foldersAdded, skipped, errors: [] });
});
const builder = { buildChunked } as unknown as VaultModelBuilder;
return { builder, buildChunked, fileMap };
}
function makeIndexer(
walker: BulkWalker,
builder: VaultModelBuilder,
over: Partial<{
markLoaded: (p: string) => void;
onProgress: (p: IndexProgress) => void;
yieldFn: () => Promise<void>;
}> = {},
) {
return new BackgroundIndexer({
makeWalker: () => walker,
makeBuilder: () => builder,
markLoaded: over.markLoaded ?? (() => { /* noop */ }),
onProgress: over.onProgress,
yieldFn: over.yieldFn,
});
}
// ─── tests ──────────────────────────────────────────────────────────────────
describe('BackgroundIndexer', () => {
describe('walks beyond depth 1 (the defect)', () => {
it('fast path: registers the whole tree, not just the root level', async () => {
const { walker, walk } = makeWalker({ fastPath: true });
// Seed what the lazy connect populate already did: the root level ONLY.
const { builder, fileMap } = makeBuilder(['root.md', 'work']);
await makeIndexer(walker, builder).start();
// One recursive walk — the daemon paginates it server-side.
expect(walk).toHaveBeenCalledExactlyOnceWith('', true);
// The deep entries the lazy populate never saw are now in the model. These
// are precisely the paths a `[[deep]]` link / `![[img.png]]` embed needs
// present in `vault.fileMap` before metadataCache will resolve them.
expect(fileMap).toContain('work/note.md');
expect(fileMap).toContain('work/sub');
expect(fileMap).toContain('work/sub/deep.md');
expect(fileMap).toContain('work/sub/img.png');
});
it('fast path: inserts parents before children (depth-ascending)', async () => {
const { walker } = makeWalker({ fastPath: true });
const { builder, buildChunked } = makeBuilder();
await makeIndexer(walker, builder).start();
// Each buildChunked call is one depth level, shallowest first, so a file's
// parent folder is always inserted in an EARLIER call than the file itself.
const depths = buildChunked.mock.calls.map(([entries]) =>
(entries as readonly RemoteEntry[]).map((e) => e.path.split('/').length - 1),
);
expect(depths).toEqual([[0, 0], [1, 1], [2, 2]]);
});
it('SFTP fallback: BFS walks one folder at a time, one level each', async () => {
const { walker, walk } = makeWalker({ fastPath: false });
const { builder, fileMap } = makeBuilder(['root.md', 'work']);
await makeIndexer(walker, builder).start();
// Root, then each folder it discovers — never a recursive walk, which on
// the fallback transport is one adapter.list per dir with no yielding.
expect(walk.mock.calls).toEqual([['', false], ['work', false], ['work/sub', false]]);
expect(fileMap).toContain('work/sub/deep.md');
expect(fileMap).toContain('work/sub/img.png');
});
});
describe('idempotency against already-inserted paths', () => {
it('re-registers nothing the connect populate already inserted', async () => {
const { walker } = makeWalker({ fastPath: true });
// Whole tree already present (the user expanded every folder, say) — the
// indexer must add nothing and skip everything.
const { builder } = makeBuilder(TREE.map((e) => e.path));
const indexer = makeIndexer(walker, builder);
await indexer.start();
expect(indexer.inserted).toEqual({ files: 0, folders: 0 });
});
it('counts only what it actually added, so progress stays honest', async () => {
const { walker } = makeWalker({ fastPath: true });
const { builder } = makeBuilder(['root.md', 'work']); // root level pre-seeded
const indexer = makeIndexer(walker, builder);
await indexer.start();
// Added: work/note.md, work/sub/deep.md, work/sub/img.png + the work/sub dir.
expect(indexer.inserted).toEqual({ files: 3, folders: 1 });
});
it('a path inserted MID-PASS by a racing lazy load / fs.changed is skipped, not double-counted', async () => {
const { walker } = makeWalker({ fastPath: false });
const { builder, fileMap, buildChunked } = makeBuilder(['root.md', 'work']);
const indexer = makeIndexer(walker, builder, {
// Simulate the click-driven LazyFolderLoader (or an fs.changed echo)
// winning the race for `work/sub/deep.md` while the BFS is mid-pass.
yieldFn: () => {
fileMap.add('work/sub/deep.md');
return Promise.resolve();
},
});
await indexer.start();
expect(fileMap).toContain('work/sub/deep.md');
// The racing insert was SKIPPED by the builder, not counted a second time:
// work/note.md + work/sub/img.png only.
expect(indexer.inserted.files).toBe(2);
const results = await Promise.all(
buildChunked.mock.results.map((r) => r.value as Promise<{ filesAdded: number }>),
);
expect(results.reduce((n, t) => n + t.filesAdded, 0)).toBe(indexer.inserted.files);
});
});
describe('marks folders loaded so the lazy loader skips them', () => {
it('fast path: marks every folder in the tree', async () => {
const { walker } = makeWalker({ fastPath: true });
const { builder } = makeBuilder();
const markLoaded = vi.fn();
await makeIndexer(walker, builder, { markLoaded }).start();
expect(markLoaded.mock.calls.map(([p]) => p as string)).toEqual(['work', 'work/sub']);
});
it('SFTP fallback: marks each folder as soon as its children are in', async () => {
const { walker } = makeWalker({ fastPath: false });
const { builder } = makeBuilder();
const markLoaded = vi.fn();
await makeIndexer(walker, builder, { markLoaded }).start();
expect(markLoaded.mock.calls.map(([p]) => p as string))
.toEqual(['', 'work', 'work/sub']);
});
it('does NOT mark a folder whose walk failed — an expand click can still retry it', async () => {
const { walker } = makeWalker({
fastPath: false,
walk: (path: string) =>
path === 'work/sub'
? Promise.reject(new Error('EACCES'))
: Promise.resolve(walkResult(childrenOf(path))),
});
const { builder } = makeBuilder();
const markLoaded = vi.fn();
await makeIndexer(walker, builder, { markLoaded }).start();
const marked = markLoaded.mock.calls.map(([p]) => p as string);
expect(marked).toContain('work'); // readable folders still land
expect(marked).not.toContain('work/sub'); // the failed one stays lazy
});
it('a cancelled fast-path pass marks nothing, so no folder falsely claims to be loaded', async () => {
const { walker } = makeWalker({ fastPath: true });
const { builder } = makeBuilder();
const markLoaded = vi.fn();
const indexer: BackgroundIndexer = makeIndexer(walker, builder, {
markLoaded,
yieldFn: () => { indexer.cancel(); return Promise.resolve(); },
});
await indexer.start();
expect(markLoaded).not.toHaveBeenCalled();
});
});
describe('stops on cancel / disconnect', () => {
it('BFS stops walking once cancelled', async () => {
const { walker, walk } = makeWalker({ fastPath: false });
const { builder } = makeBuilder();
const indexer: BackgroundIndexer = makeIndexer(walker, builder, {
// Cancel after the very first folder — what disconnect() does mid-pass.
yieldFn: () => { indexer.cancel(); return Promise.resolve(); },
});
await indexer.start();
expect(walk).toHaveBeenCalledExactlyOnceWith('', false); // never reached `work`
expect(indexer.isCancelled).toBe(true);
expect(indexer.isRunning).toBe(false);
});
it('cancelling before start means nothing is ever materialised', async () => {
const { walker } = makeWalker({ fastPath: true });
const { builder, buildChunked } = makeBuilder();
const indexer = makeIndexer(walker, builder);
indexer.cancel();
await indexer.start();
expect(buildChunked).not.toHaveBeenCalled();
expect(indexer.inserted).toEqual({ files: 0, folders: 0 });
});
it('reports cancellation on the final progress emit', async () => {
const { walker } = makeWalker({ fastPath: false });
const { builder } = makeBuilder();
const seen: IndexProgress[] = [];
const indexer: BackgroundIndexer = makeIndexer(walker, builder, {
onProgress: (p) => seen.push(p),
yieldFn: () => { indexer.cancel(); return Promise.resolve(); },
});
await indexer.start();
expect(seen[seen.length - 1]).toMatchObject({ done: true, cancelled: true });
});
it('a walk failure never sinks the session — the pass ends cleanly', async () => {
const { walker } = makeWalker({
fastPath: true,
walk: () => Promise.reject(new Error('transport gone')),
});
const { builder } = makeBuilder();
const seen: IndexProgress[] = [];
const indexer = makeIndexer(walker, builder, { onProgress: (p) => seen.push(p) });
await expect(indexer.start()).resolves.toBeUndefined();
expect(seen[seen.length - 1]).toMatchObject({ done: true, files: 0, folders: 0 });
});
it('start() is idempotent — a second call joins the in-flight run', async () => {
const { walker, walk } = makeWalker({ fastPath: true });
const { builder } = makeBuilder();
const indexer = makeIndexer(walker, builder);
await Promise.all([indexer.start(), indexer.start()]);
expect(walk).toHaveBeenCalledOnce();
});
});
describe('does not block the main thread', () => {
it('yields between every unit of work (BFS: one per folder)', async () => {
const { walker } = makeWalker({ fastPath: false });
const { builder } = makeBuilder();
const yieldFn = vi.fn(() => Promise.resolve());
await makeIndexer(walker, builder, { yieldFn }).start();
expect(yieldFn).toHaveBeenCalledTimes(3); // '', work, work/sub
});
it('fast path yields between depth levels rather than materialising in one tick', async () => {
const { walker } = makeWalker({ fastPath: true });
const { builder } = makeBuilder();
const yieldFn = vi.fn(() => Promise.resolve());
await makeIndexer(walker, builder, { yieldFn }).start();
expect(yieldFn).toHaveBeenCalledTimes(3); // depths 0, 1, 2
});
it('start() returns to the caller before doing any work', () => {
const { walker } = makeWalker({ fastPath: true });
const { builder, buildChunked } = makeBuilder();
const indexer = makeIndexer(walker, builder);
indexer.start(); // deliberately NOT awaited — this is the connect path
// Connect returns with the model untouched; the tree fills in afterwards.
expect(buildChunked).not.toHaveBeenCalled();
expect(indexer.isRunning).toBe(true);
indexer.cancel();
});
it('drives buildChunked with a smaller chunk than the connect populate (500)', async () => {
const { walker } = makeWalker({ fastPath: true });
const { builder, buildChunked } = makeBuilder();
await makeIndexer(walker, builder).start();
expect(buildChunked).toHaveBeenCalled();
for (const [, chunkSize] of buildChunked.mock.calls) {
expect(chunkSize).toBeLessThan(500);
}
});
});
describe('progress', () => {
it('emits running progress and exactly one terminal done emit', async () => {
const { walker } = makeWalker({ fastPath: true });
const { builder } = makeBuilder(['root.md', 'work']);
const seen: IndexProgress[] = [];
await makeIndexer(walker, builder, { onProgress: (p) => seen.push(p) }).start();
expect(seen.filter((p) => p.done)).toHaveLength(1);
expect(seen.some((p) => !p.done)).toBe(true);
expect(seen[seen.length - 1]).toEqual({
files: 3, folders: 1, done: true, cancelled: false,
});
});
});
});