From 76405cf2f796a18651dcae86a188c86a51f69270 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 06:28:25 +0000 Subject: [PATCH] fix(sync): fall back to adapter read for symlinked files Reading a symlinked file via Obsidian's cached vault.read(TFile) can fail (notably on mobile), which broke both refresh and push: the status check swallowed the error and mislabeled the file as 'unsynced', so the user saw a Push button that then failed re-reading the symlink. Fall back to vault.adapter.read/readBinary(path) when vault.read throws, in both the status view and the sync manager. Also stop silently swallowing the status-check error so the real cause is logged. Add a regression test covering the adapter fallback on push. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/logic/sync-manager.ts | 17 +++++++++++++---- src/ui/SyncStatusView.ts | 18 ++++++++++++++---- tests/logic/sync-manager.test.ts | 20 ++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index d3af1c0..60061e7 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -352,13 +352,22 @@ export class SyncManager { const binary = this.isBinary(path); if (typeof fileOrPath === 'string') { - return binary + return binary ? await this.app.vault.adapter.readBinary(fileOrPath) : await this.app.vault.adapter.read(fileOrPath); } - return binary - ? await this.app.vault.readBinary(fileOrPath) - : await this.app.vault.read(fileOrPath); + try { + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); + } catch (e) { + // Obsidian's cached vault.read can fail for symlinked files (notably + // on mobile); fall back to reading the path directly via the adapter. + logger.warn(`vault.read failed for ${path}; falling back to adapter`, e); + return binary + ? await this.app.vault.adapter.readBinary(path) + : await this.app.vault.adapter.read(path); + } } private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index f890ff6..dd50de2 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -449,8 +449,9 @@ export class SyncStatusView extends ItemView { const { status, diff } = this.determineFileStatus(binary, localContent, remote); this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff }); - } catch { + } catch (e) { const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + logger.warn(`Failed to determine sync status for ${path}`, e); this.fileStatuses.set(path, { file: typeof fileOrPath === 'string' ? undefined : fileOrPath, path, @@ -466,9 +467,18 @@ export class SyncStatusView extends ItemView { : await this.app.vault.adapter.read(fileOrPath as string); } if (fileOrPath instanceof TFile) { - return binary - ? await this.app.vault.readBinary(fileOrPath) - : await this.app.vault.read(fileOrPath); + try { + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); + } catch (e) { + // Obsidian's cached vault.read can fail for symlinked files + // (notably on mobile); fall back to reading the path directly. + logger.warn(`vault.read failed for ${fileOrPath.path}; falling back to adapter`, e); + return binary + ? await this.app.vault.adapter.readBinary(fileOrPath.path) + : await this.app.vault.adapter.read(fileOrPath.path); + } } // This should not happen if isStr is false and fileOrPath is TFile throw new Error('Expected TFile when isStr is false'); diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index e9dc277..4eda9cb 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -88,6 +88,26 @@ describe('SyncManager', () => { ); }); + it('falls back to the adapter when vault.read fails (e.g. symlinked file)', async () => { + const mockFile = Object.assign(new TFile(), { path: 'link.md', name: 'link.md' }); + const readSpy = vi.spyOn(mockApp.vault, 'read').mockRejectedValue(new Error('EINVAL: symlink')); + const adapterReadSpy = vi.spyOn(mockApp.vault.adapter, 'read').mockResolvedValue('linked content'); + vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'different content', sha: 'old-sha' }); + const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'link.md', sha: 'new-sha' }); + + await manager.pushFile(mockFile); + + expect(readSpy).toHaveBeenCalledWith(mockFile); + expect(adapterReadSpy).toHaveBeenCalledWith('link.md'); + expect(pushSpy).toHaveBeenCalledWith( + 'link.md', + 'linked content', + 'main', + 'Update link.md from Obsidian', + 'old-sha' + ); + }); + it('should detect conflict when remote SHA differs from last synced SHA', async () => { const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' });