diff --git a/src/services/syncService.ts b/src/services/syncService.ts index 846ec43..710ffd5 100644 --- a/src/services/syncService.ts +++ b/src/services/syncService.ts @@ -534,11 +534,12 @@ export class SyncService { owner: string, repo: string, remotePath: string, - localPath: string + localPath: string, + branch?: string ): Promise { try { console.debug(`[Sync] Pulling file: ${remotePath} -> ${localPath}`); - const content = await this.githubService.getFileContent(owner, repo, remotePath); + const content = await this.githubService.getFileContent(owner, repo, remotePath, branch); const isBin = isBinaryFile(localPath); if (isBin) { @@ -606,7 +607,8 @@ export class SyncService { owner, repo, remote.path, - absolutePath + absolutePath, + _branch ); // Store the relative path in the result for consistency result.path = change.path; @@ -861,6 +863,9 @@ export class SyncService { } result.filesProcessed = result.filesPulled + result.filesPushed + result.filesDeleted; + if (result.errors.length > 0) { + result.success = false; + } // Build new sync state const newLocalIndex = await this.buildLocalIndex(); diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 183aca6..4167987 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -336,6 +336,17 @@ export function decodeBase64(base64: string): string { for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } + + const encodingsToTry = ['utf-8', 'gb18030', 'big5', 'shift_jis'] as const; + + for (const encoding of encodingsToTry) { + try { + return new TextDecoder(encoding, { fatal: true }).decode(bytes); + } catch { + continue; + } + } + return new TextDecoder().decode(bytes); } @@ -362,4 +373,3 @@ export function decodeBase64Binary(base64: string): ArrayBuffer { } return bytes.buffer; } - diff --git a/src/views/SyncView.ts b/src/views/SyncView.ts index 5b56c6a..429ac8b 100644 --- a/src/views/SyncView.ts +++ b/src/views/SyncView.ts @@ -2,6 +2,7 @@ import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian'; import type GitHubOctokitPlugin from '../../main'; import { FileSyncState } from '../services/syncService'; import { LogEntry } from '../services/loggerService'; +import { decodeBase64, normalizePath } from '../utils/fileUtils'; export const SYNC_VIEW_TYPE = 'github-octokit-sync-view'; @@ -31,6 +32,14 @@ export class SyncView extends ItemView { this.loadViewState(); } + private toRepoPath(path: string): string { + const subfolderPath = this.plugin.settings.subfolderPath; + if (!subfolderPath) { + return path; + } + return normalizePath(`${subfolderPath}/${path}`); + } + /** Restore persisted UI state from vault-specific localStorage */ private loadViewState(): void { const state = this.plugin.app.loadLocalStorage('github-octokit-sync-view') as string | null; @@ -348,7 +357,8 @@ export class SyncView extends ItemView { if (this.plugin.settings.repo) { const ghBtn = actions.createEl('button', { text: 'GitHub', cls: 'file-action' }); ghBtn.addEventListener('click', () => { - const url = `https://github.com/${this.plugin.settings.repo!.owner}/${this.plugin.settings.repo!.name}/blob/${this.plugin.settings.repo!.branch}/${file.path}`; + const repoPath = this.toRepoPath(file.path); + const url = `https://github.com/${this.plugin.settings.repo!.owner}/${this.plugin.settings.repo!.name}/blob/${this.plugin.settings.repo!.branch}/${repoPath}`; window.open(url, '_blank'); }); } @@ -368,12 +378,14 @@ export class SyncView extends ItemView { let remoteContent = ''; if (this.plugin.settings.repo) { try { + const repoPath = this.toRepoPath(path); const remote = await this.plugin.githubService.getFileContent( this.plugin.settings.repo.owner, this.plugin.settings.repo.name, - path + repoPath, + this.plugin.settings.repo.branch ); - remoteContent = atob(remote.content); + remoteContent = decodeBase64(remote.content); } catch { // File doesn't exist on remote } diff --git a/tests/services/syncService.test.ts b/tests/services/syncService.test.ts index 73d7551..e6ee68d 100644 --- a/tests/services/syncService.test.ts +++ b/tests/services/syncService.test.ts @@ -1,4 +1,5 @@ -import { FileSyncState, LocalFileEntry, RemoteFileEntry, PersistedSyncState } from '../../src/services/syncService'; +import { App } from 'obsidian'; +import { SyncService, FileSyncState, LocalFileEntry, RemoteFileEntry, PersistedSyncState } from '../../src/services/syncService'; import { matchesIgnorePattern } from '../../src/utils/fileUtils'; /** @@ -294,6 +295,74 @@ describe('SyncService - Deletion Sync Logic', () => { }); }); +describe('SyncService - Branch-aware remote reads', () => { + it('passes the configured branch when pulling remote file contents', async () => { + const githubService = { + getFileContent: jest.fn().mockResolvedValue({ + path: 'notes/branch-only.md', + sha: 'remote-sha', + content: Buffer.from('branch content', 'utf8').toString('base64'), + encoding: 'base64', + size: 14, + }), + }; + + const app = new App(); + const service = new SyncService(app as never, githubService as never); + const changes: FileSyncState[] = [{ + path: 'notes/branch-only.md', + localHash: null, + remoteHash: 'remote-sha', + remoteSha: 'remote-sha', + status: 'added', + localModified: null, + remoteModified: null, + }]; + const remoteIndex = new Map([ + ['notes/branch-only.md', { path: 'notes/branch-only.md', sha: 'remote-sha' }], + ]); + + await service.pullChanges('octo', 'branch-repo', 'feature/sync-target', changes, remoteIndex); + + expect(githubService.getFileContent).toHaveBeenCalledWith( + 'octo', + 'branch-repo', + 'notes/branch-only.md', + 'feature/sync-target' + ); + }); +}); + +describe('SyncService - Error propagation', () => { + it('marks sync as failed when pull operations return file errors', async () => { + const githubService = { + getFileContent: jest.fn().mockRejectedValue(new Error('Not Found - https://docs.github.com/rest/repos/contents#get-repository-content')), + }; + + const app = new App(); + const service = new SyncService(app as never, githubService as never); + const remoteIndex = new Map([ + ['notes/missing.md', { path: 'notes/missing.md', sha: 'remote-sha' }], + ]); + + jest.spyOn(service, 'buildLocalIndex').mockResolvedValue(new Map()); + jest.spyOn(service, 'buildRemoteIndex').mockResolvedValue(remoteIndex); + + const { result } = await service.sync( + 'octo', + 'branch-repo', + 'feature/sync-target', + 'Sync test' + ); + + expect(result.success).toBe(false); + expect(result.filesProcessed).toBe(0); + expect(result.errors).toEqual([ + 'Not Found - https://docs.github.com/rest/repos/contents#get-repository-content', + ]); + }); +}); + describe('SyncService - Effective Ignore Patterns', () => { /** * Simulates getEffectiveIgnorePatterns logic for testing diff --git a/tests/utils/fileUtils.test.ts b/tests/utils/fileUtils.test.ts index e956c9c..a7a0308 100644 --- a/tests/utils/fileUtils.test.ts +++ b/tests/utils/fileUtils.test.ts @@ -247,6 +247,26 @@ describe('Base64 Encoding', () => { expect(decoded).toBe(original); }); + it('should auto-detect gb18030 encoded text from GitHub', () => { + const original = '函数性质与分段函数'; + const encoder = new TextEncoder(); + const utf8Bytes = encoder.encode(original); + expect(new TextDecoder('gb18030').decode(utf8Bytes)).not.toBe(original); + + const gb18030Bytes = new Uint8Array([ + 186, 175, 202, 253, 208, 212, 214, 202, 211, + 235, 183, 214, 182, 206, 186, 175, 202, 253, + ]); + let binary = ''; + for (const byte of gb18030Bytes) { + binary += String.fromCharCode(byte); + } + const encoded = btoa(binary); + + const decoded = decodeBase64(encoded); + expect(decoded).toBe(original); + }); + it('should handle empty string', () => { const encoded = encodeBase64(''); const decoded = decodeBase64(encoded); diff --git a/tests/views/SyncView.test.ts b/tests/views/SyncView.test.ts new file mode 100644 index 0000000..d615a76 --- /dev/null +++ b/tests/views/SyncView.test.ts @@ -0,0 +1,89 @@ +import { App } from 'obsidian'; +import { SyncView } from '../../src/views/SyncView'; + +describe('SyncView - Branch-aware remote reads', () => { + it('passes the configured branch when loading remote content for diff', async () => { + const app = new App(); + const githubService = { + getFileContent: jest.fn().mockResolvedValue({ + path: 'notes/branch-only.md', + sha: 'remote-sha', + content: Buffer.from('branch content', 'utf8').toString('base64'), + encoding: 'base64', + size: 14, + }), + }; + + const plugin = { + app, + settings: { + repo: { + owner: 'octo', + name: 'branch-repo', + branch: 'feature/sync-target', + }, + }, + githubService, + openDiffView: jest.fn().mockResolvedValue(null), + }; + + const view = Object.create(SyncView.prototype) as SyncView & { + app: App; + plugin: typeof plugin; + }; + view.app = app; + view.plugin = plugin; + + await view['openFileDiff']('notes/branch-only.md'); + + expect(githubService.getFileContent).toHaveBeenCalledWith( + 'octo', + 'branch-repo', + 'notes/branch-only.md', + 'feature/sync-target' + ); + }); + + it('prefixes the configured subfolder when loading remote content for diff', async () => { + const app = new App(); + const githubService = { + getFileContent: jest.fn().mockResolvedValue({ + path: 'study-workspace/notes/branch-only.md', + sha: 'remote-sha', + content: Buffer.from('branch content', 'utf8').toString('base64'), + encoding: 'base64', + size: 14, + }), + }; + + const plugin = { + app, + settings: { + repo: { + owner: 'octo', + name: 'branch-repo', + branch: 'feature/sync-target', + }, + subfolderPath: 'study-workspace', + }, + githubService, + openDiffView: jest.fn().mockResolvedValue(null), + }; + + const view = Object.create(SyncView.prototype) as SyncView & { + app: App; + plugin: typeof plugin; + }; + view.app = app; + view.plugin = plugin; + + await view['openFileDiff']('notes/branch-only.md'); + + expect(githubService.getFileContent).toHaveBeenCalledWith( + 'octo', + 'branch-repo', + 'study-workspace/notes/branch-only.md', + 'feature/sync-target' + ); + }); +});