From cc3797cb9620a60e5294e293acc30531c414c5ef Mon Sep 17 00:00:00 2001 From: Pedro Villalobos <3501656+pedrovillalobos@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:54:26 -0300 Subject: [PATCH 1/4] fix(vault-fs): list hidden entries during local traversal Obsidian's in-memory file tree omits dot-prefixed entries, so local traversal missed every hidden file and folder inside non-root folders and the decider planned removeRemote for them. Opt traversal out of the cached LIST path so it always reads the adapter. Closes #222 --- packages/plugin/src/fs/vault/index.ts | 8 +- packages/plugin/test/bidirectional.test.ts | 16 +++ packages/plugin/test/fs-vault.test.ts | 113 +++++++++++++++++++-- 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/packages/plugin/src/fs/vault/index.ts b/packages/plugin/src/fs/vault/index.ts index 543144e..4c41749 100644 --- a/packages/plugin/src/fs/vault/index.ts +++ b/packages/plugin/src/fs/vault/index.ts @@ -101,7 +101,13 @@ 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' }); + // Obsidian's cached file tree omits hidden entries + // 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/test/bidirectional.test.ts b/packages/plugin/test/bidirectional.test.ts index 8a205af..eb13b81 100644 --- a/packages/plugin/test/bidirectional.test.ts +++ b/packages/plugin/test/bidirectional.test.ts @@ -225,6 +225,22 @@ test('file with record, no local, remote unchanged → removeRemote', () => { expect(task.remote).toBe(remote); }); +// https://github.com/hesprs/sync-engine/issues/222 +test('hidden file with record, present on both sides → no removeRemote', () => { + const key = 'folder/.hidden.md'; + const local = file(key, 'local-uid'); + const remote = file(key, 'remote-uid'); + const records: RecordStatsMap = new Map([[key, fileRecord('local-uid', 'remote-uid')]]); + const tasks = runDecider({ + localStats: new Map([[key, local]]), + records, + remoteStats: new Map([[key, remote]]), + }); + + expect(names(tasks)).not.toContain('removeRemote'); + expect(tasks).toHaveLength(0); +}); + test('folder only local, no record → createRemoteDir', () => { const local = folder('docs/'); const task = keyed(runDecider({ localStats: new Map([['docs/', local]]) }), 'docs/'); diff --git a/packages/plugin/test/fs-vault.test.ts b/packages/plugin/test/fs-vault.test.ts index db982f1..76dfccf 100644 --- a/packages/plugin/test/fs-vault.test.ts +++ b/packages/plugin/test/fs-vault.test.ts @@ -1,7 +1,7 @@ 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'; @@ -42,20 +42,52 @@ type VaultControl = { writeBinary: (path: string, data: ArrayBuffer) => MaybePromise; }; +type VaultListing = { files: Array; folders: Array }; + type VaultHarness = { calls: VaultCalls; control: VaultControl; fs: RootFs; + request: VaultRequest; }; type VaultHarnessOptions = { config?: { trashOption?: 'local' }; control?: Partial; - list?: Record; folders: Array }>; + layoutReady?: boolean; + 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 +167,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 }, + workspace: { layoutReady: options.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 +283,69 @@ 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 return the same entries whether or not the layout is ready', async () => { + const ready = await listedKeys(createVaultStub({ ...HIDDEN_OPTIONS, layoutReady: true })); + const notReady = await listedKeys(createVaultStub({ ...HIDDEN_OPTIONS, layoutReady: false })); + + expect(ready).toStrictEqual(notReady); + expect(ready).toStrictEqual(HIDDEN_KEYS); +}); + +test('list should fall back to the adapter when the file tree has no such folder', async () => { + const vault = createVaultStub({ ...HIDDEN_OPTIONS, tree: {} }); + + expect(await listedKeys(vault)).toStrictEqual(HIDDEN_KEYS); +}); + +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([]); +}); From bdb9d5a8702ef3b422eb9f6fa1815b2c11d47df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C4=93sperus?= Date: Tue, 11 Aug 2026 11:55:41 +0800 Subject: [PATCH 2/4] Remove test for hidden file with no removeRemote task This case is meaningless since other cases already covers this. --- packages/plugin/test/bidirectional.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/plugin/test/bidirectional.test.ts b/packages/plugin/test/bidirectional.test.ts index eb13b81..8a205af 100644 --- a/packages/plugin/test/bidirectional.test.ts +++ b/packages/plugin/test/bidirectional.test.ts @@ -225,22 +225,6 @@ test('file with record, no local, remote unchanged → removeRemote', () => { expect(task.remote).toBe(remote); }); -// https://github.com/hesprs/sync-engine/issues/222 -test('hidden file with record, present on both sides → no removeRemote', () => { - const key = 'folder/.hidden.md'; - const local = file(key, 'local-uid'); - const remote = file(key, 'remote-uid'); - const records: RecordStatsMap = new Map([[key, fileRecord('local-uid', 'remote-uid')]]); - const tasks = runDecider({ - localStats: new Map([[key, local]]), - records, - remoteStats: new Map([[key, remote]]), - }); - - expect(names(tasks)).not.toContain('removeRemote'); - expect(tasks).toHaveLength(0); -}); - test('folder only local, no record → createRemoteDir', () => { const local = folder('docs/'); const task = keyed(runDecider({ localStats: new Map([['docs/', local]]) }), 'docs/'); From 7652727793b510e00872f14177f24f4340d1db31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?He=CC=84sperus?= Date: Tue, 11 Aug 2026 12:11:02 +0800 Subject: [PATCH 3/4] tests(vault): remove some useless cases --- packages/plugin/src/fs/vault/index.ts | 1 - packages/plugin/test/fs-vault.test.ts | 24 ++++-------------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/packages/plugin/src/fs/vault/index.ts b/packages/plugin/src/fs/vault/index.ts index 4c41749..46480b4 100644 --- a/packages/plugin/src/fs/vault/index.ts +++ b/packages/plugin/src/fs/vault/index.ts @@ -101,7 +101,6 @@ export default class VaultFs implements RootFs { let completed = 1; let total = 1; const visit = async (dir: string) => { - // Obsidian's cached file tree omits hidden entries // https://github.com/hesprs/sync-engine/issues/222 const { files, folders } = await this.request({ headers: { cached: false }, diff --git a/packages/plugin/test/fs-vault.test.ts b/packages/plugin/test/fs-vault.test.ts index 76dfccf..0110f76 100644 --- a/packages/plugin/test/fs-vault.test.ts +++ b/packages/plugin/test/fs-vault.test.ts @@ -1,3 +1,4 @@ +import type { ListedFiles } from 'obsidian'; import testKit from '$/test-kit'; import { expect, test } from 'bun:test'; import { App, TFile, TFolder } from 'obsidian'; @@ -42,8 +43,6 @@ type VaultControl = { writeBinary: (path: string, data: ArrayBuffer) => MaybePromise; }; -type VaultListing = { files: Array; folders: Array }; - type VaultHarness = { calls: VaultCalls; control: VaultControl; @@ -54,11 +53,10 @@ type VaultHarness = { type VaultHarnessOptions = { config?: { trashOption?: 'local' }; control?: Partial; - layoutReady?: boolean; - list?: Record; + list?: Record; stats?: Record; // Obsidian's in-memory file tree, which never contains hidden entries - tree?: Record; + tree?: Record; trashSystem?: Record; }; @@ -174,7 +172,7 @@ function createVaultStub(options: VaultHarnessOptions): VaultHarness { config: options.config, getAbstractFileByPath: (path: string) => cached.get(path), }, - workspace: { layoutReady: options.layoutReady ?? true }, + workspace: { layoutReady: true }, } as unknown as App; const request = createVaultRequest(app); @@ -326,20 +324,6 @@ test('list should report hidden entries the file tree omits', async () => { expect(vault.calls.list.toSorted()).toStrictEqual(['/', 'folder', 'folder/.hidden']); }); -test('list should return the same entries whether or not the layout is ready', async () => { - const ready = await listedKeys(createVaultStub({ ...HIDDEN_OPTIONS, layoutReady: true })); - const notReady = await listedKeys(createVaultStub({ ...HIDDEN_OPTIONS, layoutReady: false })); - - expect(ready).toStrictEqual(notReady); - expect(ready).toStrictEqual(HIDDEN_KEYS); -}); - -test('list should fall back to the adapter when the file tree has no such folder', async () => { - const vault = createVaultStub({ ...HIDDEN_OPTIONS, tree: {} }); - - expect(await listedKeys(vault)).toStrictEqual(HIDDEN_KEYS); -}); - test('LIST should keep using the file tree when the caller does not opt out', async () => { const vault = createVaultStub(HIDDEN_OPTIONS); From 7082c2cbe9206984ef3a61a77a18388feb9bf91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?He=CC=84sperus?= Date: Tue, 11 Aug 2026 12:13:27 +0800 Subject: [PATCH 4/4] chore(vault-request): remove useless root guard --- packages/plugin/src/fs/vault/request.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) =>