Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/services/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,11 +534,12 @@ export class SyncService {
owner: string,
repo: string,
remotePath: string,
localPath: string
localPath: string,
branch?: string
): Promise<FileSyncResult> {
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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -362,4 +373,3 @@ export function decodeBase64Binary(base64: string): ArrayBuffer {
}
return bytes.buffer;
}

18 changes: 15 additions & 3 deletions src/views/SyncView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
}
Expand All @@ -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
}
Expand Down
71 changes: 70 additions & 1 deletion tests/services/syncService.test.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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<string, RemoteFileEntry>([
['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<string, RemoteFileEntry>([
['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
Expand Down
20 changes: 20 additions & 0 deletions tests/utils/fileUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
89 changes: 89 additions & 0 deletions tests/views/SyncView.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});