mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
A shared remote root (the reporter's ~/work) is 747k entries — mostly .git / node_modules / build dirs. Even with pagination the populate is unusable: Obsidian eagerly indexes the whole vault. Fix: prune noise subtrees at the daemon so they are never walked, transferred, or indexed. - SshProfile.walkIgnoreDirs?: string[] + DEFAULT_WALK_IGNORE_DIRS (.git, node_modules, target, dist, .venv, …). DEFAULT_PROFILE seeds it so new profiles are pre-filled; older profiles fall back to the default list at walk time (an explicit [] = "ignore nothing"). - ProfileForm: multi-line "Ignore directories" textarea (one name per line) in the profile editor, placeholdered/pre-filled with defaults. - proto WalkParams.Ignore (Go) + TS mirror (ignore?: string[]). - daemon fs.walk: prune any directory whose exact basename is in the ignore set via fs.SkipDir — BEFORE counting/emitting, so pruned subtrees never shift pagination offsets. Go tests: subtree fully pruned (dir itself not emitted) + paged-with-ignore stitches the same set as a single ignored walk (no offset drift). - BulkWalker forwards ignoreDirs → fs.walk `ignore`; main.ts populate passes activeProfile.walkIgnoreDirs ?? DEFAULT. Full plugin (1050) + Go suites green; typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { BulkWalker, type AdapterListSlice, type RpcConnectionSlice } from '../src/vault/BulkWalker';
|
|
import type { WalkResult } from '../src/proto/types';
|
|
|
|
/**
|
|
* Build a fake adapter whose `list(p)` returns a stubbed
|
|
* `{files, folders}` per path. Unknown paths reply empty.
|
|
*/
|
|
function makeAdapter(map: Record<string, { files?: string[]; folders?: string[] }>): AdapterListSlice {
|
|
return {
|
|
async list(p: string) {
|
|
const entry = map[p];
|
|
return {
|
|
files: entry?.files ?? [],
|
|
folders: entry?.folders ?? [],
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a fake RPC connection that advertises `capabilities` and
|
|
* answers `fs.walk` from `walkResult`. Either `walkResult` is a
|
|
* static value or a function we can use to throw.
|
|
*/
|
|
function makeRpc(
|
|
capabilities: string[],
|
|
walkResult: WalkResult | (() => Promise<WalkResult>),
|
|
): { rpc: RpcConnectionSlice; calls: number } {
|
|
let calls = 0;
|
|
const rpc: RpcConnectionSlice = {
|
|
info: { capabilities },
|
|
rpc: {
|
|
call: vi.fn(async (method, params) => {
|
|
calls++;
|
|
expect(method).toBe('fs.walk');
|
|
expect(params.recursive).toBe(true);
|
|
return typeof walkResult === 'function' ? walkResult() : walkResult;
|
|
}) as RpcConnectionSlice['rpc']['call'],
|
|
},
|
|
};
|
|
return { rpc, calls: 0 } as unknown as { rpc: RpcConnectionSlice; calls: number };
|
|
// (we mutate `calls` indirectly via the closure; assertion sites use rpc.rpc.call.mock.calls.length)
|
|
}
|
|
|
|
describe('BulkWalker', () => {
|
|
// ─── fast path (rpc-walk) ───────────────────────────────────────────────
|
|
|
|
it('uses fs.walk when the daemon advertises the capability', async () => {
|
|
const adapter = makeAdapter({});
|
|
const { rpc } = makeRpc(['fs.list', 'fs.walk', 'fs.stat'], {
|
|
entries: [
|
|
{ path: 'docs', type: 'folder', mtime: 1000, size: 0 },
|
|
{ path: 'docs/note.md', type: 'file', mtime: 2000, size: 17 },
|
|
{ path: 'README.md', type: 'file', mtime: 3000, size: 42 },
|
|
],
|
|
truncated: false,
|
|
});
|
|
const walker = new BulkWalker({ adapter, rpcConnection: rpc });
|
|
|
|
const result = await walker.walk('');
|
|
|
|
expect(result.source).toBe('rpc-walk');
|
|
expect(result.truncated).toBe(false);
|
|
expect(result.fastPathError).toBeNull();
|
|
expect(result.entries).toHaveLength(3);
|
|
expect(result.entries[0]).toMatchObject({ path: 'docs', isDirectory: true, mtime: 1000, size: 0 });
|
|
expect(result.entries[1]).toMatchObject({ path: 'docs/note.md', isDirectory: false, mtime: 2000, size: 17 });
|
|
expect(result.entries[2]).toMatchObject({ path: 'README.md', isDirectory: false, mtime: 3000, size: 42 });
|
|
});
|
|
|
|
it('passes through the maxEntries override when set', async () => {
|
|
const adapter = makeAdapter({});
|
|
const callSpy = vi.fn(async () => ({ entries: [], truncated: false } as WalkResult));
|
|
const rpc: RpcConnectionSlice = {
|
|
info: { capabilities: ['fs.walk'] },
|
|
rpc: { call: callSpy as unknown as RpcConnectionSlice['rpc']['call'] },
|
|
};
|
|
const walker = new BulkWalker({ adapter, rpcConnection: rpc, maxEntries: 7 });
|
|
|
|
await walker.walk('');
|
|
|
|
expect(callSpy).toHaveBeenCalledWith('fs.walk', { path: '', recursive: true, maxEntries: 7 });
|
|
});
|
|
|
|
it('passes ignoreDirs through to fs.walk as `ignore`', async () => {
|
|
const adapter = makeAdapter({});
|
|
const callSpy = vi.fn(async () => ({ entries: [], truncated: false } as WalkResult));
|
|
const rpc: RpcConnectionSlice = {
|
|
info: { capabilities: ['fs.walk'] },
|
|
rpc: { call: callSpy as unknown as RpcConnectionSlice['rpc']['call'] },
|
|
};
|
|
const walker = new BulkWalker({
|
|
adapter, rpcConnection: rpc, ignoreDirs: ['node_modules', '.git'],
|
|
});
|
|
|
|
await walker.walk('');
|
|
|
|
expect(callSpy).toHaveBeenCalledWith('fs.walk', {
|
|
path: '', recursive: true, ignore: ['node_modules', '.git'],
|
|
});
|
|
});
|
|
|
|
it('omits `ignore` when ignoreDirs is empty or unset', async () => {
|
|
const adapter = makeAdapter({});
|
|
const callSpy = vi.fn(async () => ({ entries: [], truncated: false } as WalkResult));
|
|
const rpc: RpcConnectionSlice = {
|
|
info: { capabilities: ['fs.walk'] },
|
|
rpc: { call: callSpy as unknown as RpcConnectionSlice['rpc']['call'] },
|
|
};
|
|
const walker = new BulkWalker({ adapter, rpcConnection: rpc, ignoreDirs: [] });
|
|
|
|
await walker.walk('');
|
|
|
|
expect(callSpy).toHaveBeenCalledWith('fs.walk', { path: '', recursive: true });
|
|
});
|
|
|
|
// ─── fast path pagination + fallback-on-error ───────────────────────────
|
|
|
|
it('paginates: drains every page with an increasing offset, no fallback', async () => {
|
|
// The bug this guards: a truncated page used to be DISCARDED and
|
|
// replaced by an unbounded per-folder BFS that never finished on a
|
|
// huge tree → empty vault. Now it must page through and return the
|
|
// FULL stitched tree via rpc-walk.
|
|
const pages: WalkResult[] = [
|
|
{ entries: [
|
|
{ path: 'a.md', type: 'file', mtime: 1, size: 1 },
|
|
{ path: 'b.md', type: 'file', mtime: 2, size: 2 },
|
|
], truncated: true },
|
|
{ entries: [
|
|
{ path: 'c.md', type: 'file', mtime: 3, size: 3 },
|
|
{ path: 'd.md', type: 'file', mtime: 4, size: 4 },
|
|
], truncated: true },
|
|
{ entries: [
|
|
{ path: 'e.md', type: 'file', mtime: 5, size: 5 },
|
|
], truncated: false },
|
|
];
|
|
const seenOffsets: Array<number | undefined> = [];
|
|
const call = vi.fn(async (_m: string, p: { offset?: number }) => {
|
|
seenOffsets.push(p.offset);
|
|
return pages.shift()!;
|
|
});
|
|
const rpc: RpcConnectionSlice = {
|
|
info: { capabilities: ['fs.walk'] },
|
|
rpc: { call: call as unknown as RpcConnectionSlice['rpc']['call'] },
|
|
};
|
|
const walker = new BulkWalker({ adapter: makeAdapter({}), rpcConnection: rpc });
|
|
|
|
const result = await walker.walk('');
|
|
|
|
expect(result.source).toBe('rpc-walk');
|
|
expect(result.truncated).toBe(false);
|
|
expect(result.fastPathError).toBeNull();
|
|
expect(result.pages).toBe(3);
|
|
expect(result.entries.map(e => e.path)).toEqual(['a.md', 'b.md', 'c.md', 'd.md', 'e.md']);
|
|
// Real mtime/size preserved across pages (fast path, not the zeroed fallback).
|
|
expect(result.entries[4]).toMatchObject({ path: 'e.md', mtime: 5, size: 5 });
|
|
// Page 1 has no offset; pages 2/3 carry the running total.
|
|
expect(seenOffsets).toEqual([undefined, 2, 4]);
|
|
});
|
|
|
|
it('stops at the empty-page guard when the daemon truncates but delivers nothing', async () => {
|
|
const pages: WalkResult[] = [
|
|
{ entries: [{ path: 'a.md', type: 'file', mtime: 1, size: 1 }], truncated: true },
|
|
{ entries: [], truncated: true }, // daemon can't advance — must not spin
|
|
];
|
|
const call = vi.fn(async () => pages.shift()!);
|
|
const rpc: RpcConnectionSlice = {
|
|
info: { capabilities: ['fs.walk'] },
|
|
rpc: { call: call as unknown as RpcConnectionSlice['rpc']['call'] },
|
|
};
|
|
const walker = new BulkWalker({ adapter: makeAdapter({}), rpcConnection: rpc });
|
|
|
|
const result = await walker.walk('');
|
|
|
|
expect(call).toHaveBeenCalledTimes(2);
|
|
expect(result.source).toBe('rpc-walk');
|
|
expect(result.truncated).toBe(true); // incomplete, surfaced
|
|
expect(result.fastPathError).toBe('page-guard');
|
|
expect(result.entries.map(e => e.path)).toEqual(['a.md']);
|
|
});
|
|
|
|
it('falls back when the fs.walk RPC throws', async () => {
|
|
const adapter = makeAdapter({
|
|
'': { folders: ['x'], files: ['y.md'] },
|
|
'x': { files: ['x/z.md'] },
|
|
});
|
|
const { rpc } = makeRpc(['fs.walk'], async () => { throw new Error('connection reset'); });
|
|
const walker = new BulkWalker({ adapter, rpcConnection: rpc });
|
|
|
|
const result = await walker.walk('');
|
|
|
|
expect(result.source).toBe('fallback-list');
|
|
expect(result.fastPathError).toBe('connection reset');
|
|
expect(result.entries.map(e => e.path).sort()).toEqual(['x', 'x/z.md', 'y.md']);
|
|
});
|
|
|
|
// ─── no fast path available ─────────────────────────────────────────────
|
|
|
|
it('skips the fast path when no RPC connection is injected (= SFTP transport)', async () => {
|
|
const adapter = makeAdapter({
|
|
'': { folders: ['notes'] },
|
|
'notes': { files: ['notes/today.md'] },
|
|
});
|
|
const walker = new BulkWalker({ adapter /* no rpcConnection */ });
|
|
|
|
const result = await walker.walk('');
|
|
|
|
expect(result.source).toBe('fallback-list');
|
|
expect(result.fastPathError).toBeNull();
|
|
expect(result.entries.map(e => e.path).sort()).toEqual(['notes', 'notes/today.md']);
|
|
});
|
|
|
|
it('skips the fast path when the daemon does not advertise fs.walk', async () => {
|
|
const adapter = makeAdapter({ '': { files: ['a.md'] } });
|
|
const callSpy = vi.fn();
|
|
const rpc: RpcConnectionSlice = {
|
|
info: { capabilities: ['fs.list', 'fs.stat'] }, // no fs.walk
|
|
rpc: { call: callSpy as unknown as RpcConnectionSlice['rpc']['call'] },
|
|
};
|
|
const walker = new BulkWalker({ adapter, rpcConnection: rpc });
|
|
|
|
const result = await walker.walk('');
|
|
|
|
expect(callSpy).not.toHaveBeenCalled();
|
|
expect(result.source).toBe('fallback-list');
|
|
expect(result.entries.map(e => e.path)).toEqual(['a.md']);
|
|
});
|
|
|
|
// ─── fallback resilience ────────────────────────────────────────────────
|
|
|
|
it('fallback skips folders whose list() throws and keeps walking siblings', async () => {
|
|
let listCalls = 0;
|
|
const adapter: AdapterListSlice = {
|
|
async list(p: string) {
|
|
listCalls++;
|
|
if (p === 'broken') throw new Error('permission denied');
|
|
if (p === '') return { files: ['ok.md'], folders: ['broken', 'good'] };
|
|
if (p === 'good') return { files: ['good/inside.md'], folders: [] };
|
|
return { files: [], folders: [] };
|
|
},
|
|
};
|
|
const walker = new BulkWalker({ adapter });
|
|
|
|
const result = await walker.walk('');
|
|
|
|
expect(result.source).toBe('fallback-list');
|
|
expect(result.entries.map(e => e.path).sort()).toEqual(['broken', 'good', 'good/inside.md', 'ok.md']);
|
|
expect(listCalls).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ─── walkMs timing ──────────────────────────────────────────────────────
|
|
|
|
it('records a non-negative walkMs', async () => {
|
|
const adapter = makeAdapter({ '': { files: ['x.md'] } });
|
|
const walker = new BulkWalker({ adapter });
|
|
const result = await walker.walk('');
|
|
expect(result.walkMs).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|