diff --git a/packages/plugin/src/fs/vault/index.ts b/packages/plugin/src/fs/vault/index.ts index 543144e..46480b4 100644 --- a/packages/plugin/src/fs/vault/index.ts +++ b/packages/plugin/src/fs/vault/index.ts @@ -101,7 +101,12 @@ export default class VaultFs implements RootFs { let completed = 1; let total = 1; const visit = async (dir: string) => { - const { files, folders } = await this.request({ key: dir, method: 'LIST' }); + // https://github.com/hesprs/sync-engine/issues/222 + const { files, folders } = await this.request({ + headers: { cached: false }, + key: dir, + method: 'LIST', + }); completed++; total += files.length + folders.length; await Promise.all([ diff --git a/packages/plugin/src/fs/vault/request.ts b/packages/plugin/src/fs/vault/request.ts index 466acea..011f9f7 100644 --- a/packages/plugin/src/fs/vault/request.ts +++ b/packages/plugin/src/fs/vault/request.ts @@ -99,7 +99,7 @@ export default function createVaultRequest(app: App): VaultRequest { } if (method === 'LIST') { const children: ListedFiles = { files: [], folders: [] }; - if (canUseCache() && (params.headers?.cached ?? true) && key !== '/') { + if (canUseCache() && (params.headers?.cached ?? true)) { const folder = vault.getAbstractFileByPath(path); if (folder instanceof TFolder) { folder.children.forEach((child) => diff --git a/packages/plugin/test/fs-vault.test.ts b/packages/plugin/test/fs-vault.test.ts index db982f1..0110f76 100644 --- a/packages/plugin/test/fs-vault.test.ts +++ b/packages/plugin/test/fs-vault.test.ts @@ -1,7 +1,8 @@ +import type { ListedFiles } from 'obsidian'; import testKit from '$/test-kit'; import { expect, test } from 'bun:test'; -import { App } from 'obsidian'; -import type { RootFs } from '@/fs'; +import { App, TFile, TFolder } from 'obsidian'; +import type { RootFs, VaultRequest } from '@/fs'; import type { MaybePromise } from '@/types'; import { createVaultRequest, VaultFs } from '@/fs'; @@ -46,16 +47,45 @@ type VaultHarness = { calls: VaultCalls; control: VaultControl; fs: RootFs; + request: VaultRequest; }; type VaultHarnessOptions = { config?: { trashOption?: 'local' }; control?: Partial; - list?: Record; folders: Array }>; + list?: Record; stats?: Record; + // Obsidian's in-memory file tree, which never contains hidden entries + tree?: Record; trashSystem?: Record; }; +function createCachedTree(options: VaultHarnessOptions): Map { + const cached = new Map(); + const toFile = (path: string) => { + const stat = options.stats?.[path]; + return Object.assign(new TFile(), { + path, + stat: { ctime: 0, mtime: stat?.mtime ?? 0, size: stat?.size ?? 0 }, + }); + }; + for (const [path, { files, folders }] of Object.entries(options.tree ?? {})) { + const children = files.map(toFile); + for (const child of children) cached.set(child.path, child); + cached.set( + path, + Object.assign(new TFolder(), { + children: [ + ...folders.map((child) => Object.assign(new TFolder(), { path: child })), + ...children, + ], + path, + }), + ); + } + return cached; +} + function createVaultControl(options: VaultHarnessOptions): VaultControl { return { appendBinary: () => {}, @@ -135,19 +165,22 @@ function createVaultStub(options: VaultHarnessOptions): VaultHarness { }, }; + const cached = createCachedTree(options); const app = { vault: { adapter, config: options.config, - getAbstractFileByPath: () => {}, + getAbstractFileByPath: (path: string) => cached.get(path), }, workspace: { layoutReady: true }, } as unknown as App; + const request = createVaultRequest(app); return { calls, control, - fs: new VaultFs(createVaultRequest(app), 'Vault Name'), + fs: new VaultFs(request, 'Vault Name'), + request, }; } @@ -248,3 +281,55 @@ test('list should DFS descendants and exclude queried root', async () => { ]); expect(stats.some(({ key }) => key === '/')).toBe(false); }); + +// Hidden entries live on disk but never appear in Obsidian's in-memory file tree +const HIDDEN_OPTIONS: VaultHarnessOptions = { + list: { + '/': { files: ['root.md', '.hidden-root.md'], folders: ['folder'] }, + folder: { files: ['folder/note.md', 'folder/.hidden.md'], folders: ['folder/.hidden'] }, + 'folder/.hidden': { files: ['folder/.hidden/inner.md'], folders: [] }, + }, + stats: { + '.hidden-root.md': { mtime: 5, size: 5, type: 'file' }, + 'folder/.hidden.md': { mtime: 2, size: 2, type: 'file' }, + 'folder/.hidden/inner.md': { mtime: 3, size: 3, type: 'file' }, + 'folder/note.md': { mtime: 4, size: 4, type: 'file' }, + 'root.md': { mtime: 1, size: 1, type: 'file' }, + }, + tree: { + '/': { files: ['root.md'], folders: ['folder'] }, + folder: { files: ['folder/note.md'], folders: [] }, + }, +}; + +const HIDDEN_KEYS = [ + '.hidden-root.md', + 'folder/', + 'folder/.hidden.md', + 'folder/.hidden/', + 'folder/.hidden/inner.md', + 'folder/note.md', + 'root.md', +].toSorted(); + +async function listedKeys(vault: VaultHarness): Promise> { + const stats = await vault.fs.list('/', () => 'advance'); + return stats.map(({ key }) => key).toSorted(); +} + +test('list should report hidden entries the file tree omits', async () => { + const vault = createVaultStub(HIDDEN_OPTIONS); + + expect(await listedKeys(vault)).toStrictEqual(HIDDEN_KEYS); + expect(vault.calls.list.toSorted()).toStrictEqual(['/', 'folder', 'folder/.hidden']); +}); + +test('LIST should keep using the file tree when the caller does not opt out', async () => { + const vault = createVaultStub(HIDDEN_OPTIONS); + + expect(await vault.request({ key: 'folder/', method: 'LIST' })).toStrictEqual({ + files: ['folder/note.md'], + folders: [], + }); + expect(vault.calls.list).toStrictEqual([]); +});