mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
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:
commit
5b0442cb41
8 changed files with 751 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4
plugin/package-lock.json
generated
4
plugin/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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, ` +
|
||||
|
|
|
|||
286
plugin/src/vault/BackgroundIndexer.ts
Normal file
286
plugin/src/vault/BackgroundIndexer.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
370
plugin/tests/BackgroundIndexer.test.ts
Normal file
370
plugin/tests/BackgroundIndexer.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue