Fix lint (#15)

* ci: optimize workflow to reduce redundancy and refine permissions

* ci: fix syntax errors in workflow file

* docs: resolve SonarCloud issues and refactor for complexity

* refactor: extract BaseGitService to reduce duplication and fix lint/tests

* fix: resolve TS build errors due to unchecked indexed access

* refactor: address gemini-code-assist bot reviews

- Add last_commit_id to GitLabFileResponse
- Use last_commit_id as sha in GitLabService.getFile
- Add rename detection to SyncManager.processSingleBatchPush

---------

Co-authored-by: ClaudiaFang <tianyao.firstsun@gmail.coim>
This commit is contained in:
ClaudiaFang 2026-04-26 05:23:13 +08:00 committed by GitHub
parent f33ef3e3fd
commit 655cd69252
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 725 additions and 833 deletions

View file

@ -98,6 +98,15 @@ jobs:
with:
args: >
-Dsonar.qualitygate.wait=true
-Dsonar.scanner.dumpToFile=sonar-project.properties
- name: Upload Sonar logs
if: always()
uses: actions/upload-artifact@v7
with:
name: sonar-scan-logs
path: |
.scannerwork/
sonar-project.properties
artifact:
name: Package Artifact

View file

@ -3,14 +3,14 @@ import { App } from 'obsidian';
import { GitServiceInterface } from '../services/git-service-interface';
export class GitignoreManager {
private app: App;
private gitService: GitServiceInterface;
private branch: string;
private readonly app: App;
private readonly gitService: GitServiceInterface;
private readonly branch: string;
private rootPath: string;
private readonly rootPath: string;
// Maps directory path (empty string for root) to Ignore instance
private ignoreMap: Map<string, Ignore> = new Map();
private readonly ignoreMap: Map<string, Ignore> = new Map();
constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string) {
this.app = app;
@ -38,39 +38,7 @@ export class GitignoreManager {
// 2. Fetch and parse each .gitignore
for (const fullGitignorePath of gitignorePaths) {
const dirPath = fullGitignorePath === '.gitignore' ? '' : fullGitignorePath.slice(0, -('.gitignore'.length + 1));
let content: string | undefined;
// Determine local path relative to vault root
let localPath: string | null = null;
if (!this.rootPath) {
localPath = fullGitignorePath;
} else if (fullGitignorePath === this.rootPath + '/.gitignore' || fullGitignorePath.startsWith(this.rootPath + '/')) {
localPath = fullGitignorePath.substring(this.rootPath.length + 1);
}
// Try local first if it's within the vault
if (localPath) {
try {
if (await this.app.vault.adapter.exists(localPath)) {
content = await this.app.vault.adapter.read(localPath);
}
} catch (e) {
console.warn(`Failed to read local ${localPath}`, e);
}
}
// Fallback to remote (use absolute path starting with / to bypass rootPath)
if (content === undefined) {
try {
const remoteFile = await this.gitService.getFile('/' + fullGitignorePath, this.branch);
if (remoteFile && remoteFile.content) {
content = remoteFile.content;
}
} catch {
// It's okay if some gitignores fail to fetch
}
}
const content = await this.getGitignoreContent(fullGitignorePath);
if (content) {
const ig = ignore().add(content);
@ -79,6 +47,42 @@ export class GitignoreManager {
}
}
private async getGitignoreContent(fullGitignorePath: string): Promise<string | undefined> {
let content: string | undefined;
// Determine local path relative to vault root
let localPath: string | null = null;
if (!this.rootPath) {
localPath = fullGitignorePath;
} else if (fullGitignorePath === this.rootPath + '/.gitignore' || fullGitignorePath.startsWith(this.rootPath + '/')) {
localPath = fullGitignorePath.substring(this.rootPath.length + 1);
}
// Try local first if it's within the vault
if (localPath) {
try {
if (await this.app.vault.adapter.exists(localPath)) {
content = await this.app.vault.adapter.read(localPath);
}
} catch (e) {
console.warn(`Failed to read local ${localPath}`, e);
}
}
// Fallback to remote (use absolute path starting with / to bypass rootPath)
if (content === undefined) {
try {
const remoteFile = await this.gitService.getFile('/' + fullGitignorePath, this.branch);
if (remoteFile?.content) {
content = remoteFile.content;
}
} catch {
// It's okay if some gitignores fail to fetch
}
}
return content;
}
/**
* Checks if a given file path should be ignored based on loaded .gitignore rules.
*/

View file

@ -4,9 +4,9 @@ import { GitLabFilesPushSettings } from '../settings';
import { SyncConflictModal } from '../ui/SyncConflictModal';
export class SyncManager {
private app: App;
private readonly app: App;
private gitService: GitServiceInterface;
private settings: GitLabFilesPushSettings;
private readonly settings: GitLabFilesPushSettings;
constructor(app: App, gitService: GitServiceInterface, settings: GitLabFilesPushSettings) {
this.app = app;
@ -19,21 +19,14 @@ export class SyncManager {
}
async pushFile(fileOrPath: TFile | string) {
const isString = typeof fileOrPath === 'string';
const path = isString ? fileOrPath : fileOrPath.path;
const name = isString ? path.split('/').pop() || path : fileOrPath.name;
const { path, name, isString } = this.getFileInfo(fileOrPath);
if (isString) {
if (!(await this.app.vault.adapter.exists(path))) {
new Notice(`File ${name} no longer exists in vault.`);
return;
}
} else if (!this.app.vault.getFileByPath(path)) {
if (!await this.checkFileExists(path, isString)) {
new Notice(`File ${name} no longer exists in vault.`);
return;
}
const content = isString ? await this.app.vault.adapter.read(path) : (fileOrPath instanceof TFile ? await this.app.vault.read(fileOrPath) : '');
const content = await this.getFileContent(fileOrPath);
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
try {
// Check if this is a renamed file
@ -54,10 +47,11 @@ export class SyncManager {
new SyncConflictModal(this.app, name, content, remote.content, (choice) => {
void (async () => {
try {
const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath;
if (choice === 'local') {
await this.performPush({ path, name }, content, remote.sha);
} else {
await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha);
await this.performPull(fileRep, remote.content, remote.sha);
}
} catch (e) {
console.error(e);
@ -152,16 +146,9 @@ export class SyncManager {
}
async pullFile(fileOrPath: TFile | string) {
const isString = typeof fileOrPath === 'string';
const path = isString ? fileOrPath : fileOrPath.path;
const name = isString ? path.split('/').pop() || path : fileOrPath.name;
const { path, name, isString } = this.getFileInfo(fileOrPath);
if (isString) {
if (!(await this.app.vault.adapter.exists(path))) {
new Notice(`File ${name} no longer exists in vault.`);
return;
}
} else if (!this.app.vault.getFileByPath(path)) {
if (!await this.checkFileExists(path, isString)) {
new Notice(`File ${name} no longer exists in vault.`);
return;
}
@ -173,7 +160,7 @@ export class SyncManager {
new Notice(`File ${name} not found on remote.`);
return;
}
const localContent = isString ? await this.app.vault.adapter.read(path) : (fileOrPath instanceof TFile ? await this.app.vault.read(fileOrPath) : '');
const localContent = await this.getFileContent(fileOrPath);
const lastSynced = this.settings.syncMetadata[path];
if (localContent === remote.content) {
@ -192,10 +179,11 @@ export class SyncManager {
new SyncConflictModal(this.app, name, localContent, remote.content, (choice) => {
void (async () => {
try {
const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath;
if (choice === 'local') {
await this.performPush({ path, name }, localContent, remote.sha);
} else {
await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha);
await this.performPull(fileRep, remote.content, remote.sha);
}
} catch (e) {
console.error(e);
@ -206,7 +194,8 @@ export class SyncManager {
return;
}
await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha);
const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath;
await this.performPull(fileRep, remote.content, remote.sha);
} catch (e) {
console.error(e);
new Notice(`Failed to pull ${name} from ${serviceName}: ${e instanceof Error ? e.message : String(e)}`);
@ -230,7 +219,7 @@ export class SyncManager {
};
await this.saveSettings();
const name = file instanceof TFile ? file.name : file.name;
const name = file.name;
new Notice(`Pulled ${name} from ${serviceName}`);
}
@ -262,9 +251,7 @@ export class SyncManager {
const fileOrPath = files[i];
if (!fileOrPath) continue;
const isString = typeof fileOrPath === 'string';
const path = isString ? fileOrPath : fileOrPath.path;
const name = isString ? path.split('/').pop() || path : fileOrPath.name;
const { path, name, isString } = this.getFileInfo(fileOrPath);
if (onProgress) {
onProgress(i + 1, files.length, name);
@ -272,32 +259,9 @@ export class SyncManager {
try {
if (op === 'push') {
let content: string;
if (isString) {
if (!(await this.app.vault.adapter.exists(path))) throw new Error('File no longer exists');
content = await this.app.vault.adapter.read(path);
} else {
const existingFile = this.app.vault.getFileByPath(path);
if (!existingFile) throw new Error('File no longer exists');
content = await this.app.vault.read(existingFile);
}
const remote = await this.gitService.getFile(path, this.settings.branch);
await this.gitService.pushFile(path, content, this.settings.branch, `Update ${name} from Obsidian`, remote.sha || undefined);
const newRemote = await this.gitService.getFile(path, this.settings.branch);
this.settings.syncMetadata[path] = { lastSyncedSha: newRemote.sha, lastSyncedAt: Date.now(), lastKnownPath: path };
await this.processSingleBatchPush(fileOrPath, path, name, isString);
} else {
const remote = await this.gitService.getFile(path, this.settings.branch);
if (!remote.sha) throw new Error('File not found in remote');
if (isString) {
await this.app.vault.adapter.write(path, remote.content);
} else if (fileOrPath instanceof TFile) {
await this.app.vault.modify(fileOrPath, remote.content);
}
this.settings.syncMetadata[path] = { lastSyncedSha: remote.sha, lastSyncedAt: Date.now(), lastKnownPath: path };
await this.processSingleBatchPull(fileOrPath, path, name, isString);
}
results.success++;
} catch (e) {
@ -313,4 +277,57 @@ export class SyncManager {
return results;
}
private getFileInfo(fileOrPath: TFile | string) {
const isString = typeof fileOrPath === 'string';
const path = isString ? fileOrPath : fileOrPath.path;
const name = isString ? path.split('/').pop() || path : fileOrPath.name;
return { path, name, isString };
}
private async checkFileExists(path: string, isString: boolean): Promise<boolean> {
if (isString) {
return await this.app.vault.adapter.exists(path);
}
return !!this.app.vault.getFileByPath(path);
}
private async getFileContent(fileOrPath: TFile | string): Promise<string> {
if (typeof fileOrPath === 'string') {
return await this.app.vault.adapter.read(fileOrPath);
}
return await this.app.vault.read(fileOrPath);
}
private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean) {
if (!await this.checkFileExists(path, isString)) throw new Error('File no longer exists');
const content = await this.getFileContent(fileOrPath);
// Rename detection
if (!isString && fileOrPath instanceof TFile) {
const renamedFrom = this.detectRename(fileOrPath);
if (renamedFrom) {
await this.handleRename(fileOrPath, renamedFrom, content);
return;
}
}
const remote = await this.gitService.getFile(path, this.settings.branch);
await this.gitService.pushFile(path, content, this.settings.branch, `Update ${name} from Obsidian`, remote.sha || undefined);
const newRemote = await this.gitService.getFile(path, this.settings.branch);
this.settings.syncMetadata[path] = { lastSyncedSha: newRemote.sha, lastSyncedAt: Date.now(), lastKnownPath: path };
}
private async processSingleBatchPull(fileOrPath: TFile | string, path: string, name: string, isString: boolean) {
const remote = await this.gitService.getFile(path, this.settings.branch);
if (!remote.sha) throw new Error('File not found in remote');
if (typeof fileOrPath === 'string') {
await this.app.vault.adapter.write(fileOrPath, remote.content);
} else if (fileOrPath instanceof TFile) {
await this.app.vault.modify(fileOrPath, remote.content);
}
this.settings.syncMetadata[path] = { lastSyncedSha: remote.sha, lastSyncedAt: Date.now(), lastKnownPath: path };
}
}

View file

@ -23,15 +23,15 @@ export default class GitLabFilesPush extends Plugin {
(leaf) => new SyncStatusView(leaf, this)
);
this.addRibbonIcon('list-checks', 'Open sync status', () => {
void this.activateSyncStatusView();
this.addRibbonIcon('list-checks', 'Open sync status', async () => {
await this.activateSyncStatusView();
});
this.addCommand({
id: 'open-sync-status',
name: 'Open sync status',
callback: () => {
void this.activateSyncStatusView();
callback: async () => {
await this.activateSyncStatusView();
}
});
@ -41,10 +41,10 @@ export default class GitLabFilesPush extends Plugin {
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
this.addRibbonIcon('upload-cloud', Platform.isMobile ? `Push` : `Push to ${serviceName}`, () => {
this.addRibbonIcon('upload-cloud', Platform.isMobile ? `Push` : `Push to ${serviceName}`, async () => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.file instanceof TFile) {
void this.sync.pushFile(activeView.file);
await this.sync.pushFile(activeView.file);
} else {
new Notice('No active note to push');
}
@ -53,10 +53,10 @@ export default class GitLabFilesPush extends Plugin {
this.addCommand({
id: 'push-current-file',
name: `Push current file to ${serviceName}`,
callback: () => {
callback: async () => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.file instanceof TFile) {
void this.sync.pushFile(activeView.file);
await this.sync.pushFile(activeView.file);
}
}
});
@ -64,10 +64,10 @@ export default class GitLabFilesPush extends Plugin {
this.addCommand({
id: 'pull-current-file',
name: `Pull current file from ${serviceName}`,
callback: () => {
callback: async () => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.file instanceof TFile) {
void this.sync.pullFile(activeView.file);
await this.sync.pullFile(activeView.file);
}
}
});
@ -75,16 +75,16 @@ export default class GitLabFilesPush extends Plugin {
this.addCommand({
id: 'push-all-files',
name: 'Push all files',
callback: () => {
void this.pushAllFiles();
callback: async () => {
await this.pushAllFiles();
}
});
this.addCommand({
id: 'pull-all-files',
name: 'Pull all files',
callback: () => {
void this.pullAllFiles();
callback: async () => {
await this.pullAllFiles();
}
});
@ -94,12 +94,12 @@ export default class GitLabFilesPush extends Plugin {
menu.addItem((item) => {
item.setTitle(`Push to ${serviceName}`)
.setIcon('upload-cloud')
.onClick(() => { void this.sync.pushFile(file); });
.onClick(async () => { await this.sync.pushFile(file); });
});
menu.addItem((item) => {
item.setTitle(`Pull from ${serviceName}`)
.setIcon('download-cloud')
.onClick(() => { void this.sync.pullFile(file); });
.onClick(async () => { await this.sync.pullFile(file); });
});
}
})
@ -132,7 +132,7 @@ export default class GitLabFilesPush extends Plugin {
}
if (leaf) {
void workspace.revealLeaf(leaf);
await workspace.revealLeaf(leaf);
}
}
@ -219,19 +219,23 @@ export default class GitLabFilesPush extends Plugin {
initializeGitService(): void {
if (this.settings.serviceType === 'gitlab') {
this.gitService = new GitLabService(
const service = new GitLabService();
service.updateConfig(
this.settings.gitlabBaseUrl,
this.settings.gitlabToken,
this.settings.projectId,
this.settings.rootPath
);
this.gitService = service;
} else {
this.gitService = new GitHubService(
const service = new GitHubService();
service.updateConfig(
this.settings.githubToken,
this.settings.githubOwner,
this.settings.githubRepo,
this.settings.rootPath
);
this.gitService = service;
}
if (this.sync) {

View file

@ -0,0 +1,97 @@
import { requestUrl, RequestUrlResponse } from 'obsidian';
export interface GitFile {
content: string;
sha: string;
}
export interface GitHubContentResponse {
content: string;
sha: string;
path: string;
}
export interface GitHubTreeItem {
path: string;
type: string;
}
export interface GitHubTreeResponse {
tree: GitHubTreeItem[];
}
export interface GitLabFileResponse {
content: string;
blob_id: string;
file_path: string;
last_commit_id: string;
}
export interface GitLabTreeItem {
path: string;
type: string;
}
export abstract class BaseGitService {
protected token: string = '';
protected rootPath: string = '';
/**
* Safely wraps requestUrl to handle potential throws from Obsidian and provide better error messages.
*/
protected async safeRequest(url: string, method: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<RequestUrlResponse> {
try {
const headers: Record<string, string> = {
...extraHeaders,
'Content-Type': 'application/json',
};
this.addAuthHeader(headers);
const options = {
url,
method,
headers,
body: body ? JSON.stringify(body) : undefined,
throw: false
};
const response = await requestUrl(options);
if (response.status >= 400) {
const errorMsg = this.parseErrorResponse(response);
throw new Error(`Git Service Error (${response.status}): ${errorMsg}`);
}
return response;
} catch (error) {
console.error('Git Service Request Failed:', error);
if (error instanceof Error) throw error;
throw new Error(`Network error or unexpected failure: ${String(error)}`);
}
}
protected abstract addAuthHeader(headers: Record<string, string>): void;
protected parseErrorResponse(response: RequestUrlResponse): string {
try {
const data = response.json as { message?: string; error?: string };
return data.message || data.error || JSON.stringify(data);
} catch {
return response.text || 'Unknown error';
}
}
protected getFullPath(path: string): string {
if (!this.rootPath) return path;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return cleanRoot + cleanPath;
}
abstract getFile(path: string, branch: string): Promise<GitFile>;
abstract pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise<string>;
abstract listFiles(branch: string): Promise<string[]>;
abstract deleteFile(path: string, branch: string, message: string): Promise<void>;
abstract testConnection(): Promise<boolean>;
abstract getRepoGitignores(branch: string): Promise<string[]>;
}

View file

@ -2,7 +2,7 @@ export interface GitServiceInterface {
updateConfig(...args: unknown[]): void;
getFile(path: string, branch: string): Promise<{ content: string; sha: string }>;
pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise<string>;
testConnection(): Promise<void>;
testConnection(): Promise<boolean>;
listFiles(branch: string, path?: string): Promise<string[]>;
deleteFile(path: string, branch: string, commitMessage: string): Promise<void>;
getRepoGitignores(branch: string): Promise<string[]>;

View file

@ -1,285 +1,114 @@
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base';
interface GitHubFileResponse {
content: string;
sha: string;
path: string;
}
interface ObsidianErrorResponse {
headers: Record<string, string>;
json?: unknown;
text?: string;
}
interface ObsidianResponseError {
status: number;
response?: ObsidianErrorResponse;
}
export class GitHubService implements GitServiceInterface {
private token: string;
private owner: string;
private repo: string;
private rootPath: string;
constructor(token: string, owner: string, repo: string, rootPath: string = '') {
this.updateConfig(token, owner, repo, rootPath);
}
export class GitHubService extends BaseGitService implements GitServiceInterface {
private owner: string = '';
private repo: string = '';
updateConfig(token: string, owner: string, repo: string, rootPath: string = '') {
this.token = token;
this.owner = owner;
this.repo = repo;
this.rootPath = rootPath.replace(/^\/|\/$/g, '');
this.rootPath = rootPath;
}
protected addAuthHeader(headers: Record<string, string>): void {
headers['Authorization'] = `token ${this.token}`;
}
private getApiUrl(path: string): string {
const isAbsolute = path.startsWith('/');
const cleanPath = path.replace(/^\//, '');
const fullPath = (this.rootPath && !isAbsolute) ? `${this.rootPath}/${cleanPath}` : cleanPath;
const fullPath = this.getFullPath(path);
return `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${fullPath}`;
}
private async safeRequest(params: RequestUrlParam): Promise<RequestUrlResponse> {
async getFile(path: string, branch: string): Promise<GitFile> {
try {
return await requestUrl(params);
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitHubContentResponse;
return {
content: this.decodeContent(data.content),
sha: data.sha
};
} catch (e) {
if (typeof e === 'object' && e !== null && 'status' in e) {
const error = e as ObsidianResponseError;
const status = error.status || 0;
const responseData = error.response;
const text = responseData?.text || (responseData?.json ? JSON.stringify(responseData.json) : '');
if (status) {
return {
status,
headers: responseData?.headers || {},
arrayBuffer: new ArrayBuffer(0),
json: responseData?.json || {},
text: text
};
}
if (e instanceof Error && e.message.includes('404')) {
return { content: '', sha: '' };
}
throw e;
}
}
async getFile(path: string, branch: string): Promise<{ content: string; sha: string }> {
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest({
url,
method: 'GET',
headers: {
'Authorization': `token ${this.token}`,
'Accept': 'application/vnd.github.v3+json'
}
});
if (response.status === 404) {
return { content: '', sha: '' };
}
if (response.status !== 200) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to fetch file: ${response.status} from ${url}. Response: ${errorBody}`);
}
const data = (response.json as unknown) as GitHubFileResponse;
const decodedContent = this.fromBase64(data.content);
return {
content: decodedContent,
sha: data.sha
async pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise<string> {
const url = this.getApiUrl(path);
const body = {
message,
content: this.encodeContent(content),
branch,
sha
};
const response = await this.safeRequest(url, 'PUT', body);
const data = response.json as { content: { path: string } };
return data.content.path;
}
async pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise<string> {
async listFiles(branch: string): Promise<string[]> {
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitHubTreeResponse;
return data.tree
.filter(item => item.type === 'blob')
.map(item => item.path)
.filter(p => !this.rootPath || p.startsWith(this.rootPath));
}
async deleteFile(path: string, branch: string, message: string): Promise<void> {
const file = await this.getFile(path, branch);
const url = this.getApiUrl(path);
const sha = existingSha !== undefined ? existingSha : (await this.getFile(path, branch)).sha;
const body: Record<string, unknown> = {
message: commitMessage,
content: this.toBase64(content),
const body = {
message,
sha: file.sha,
branch
};
if (sha) {
body.sha = sha;
}
const response = await this.safeRequest({
url,
method: 'PUT',
headers: {
'Authorization': `token ${this.token}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (response.status !== 200 && response.status !== 201) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to push file: ${response.status} PUT ${url}. Response: ${errorBody}`);
}
return ((response.json as { content: GitHubFileResponse }).content).path;
await this.safeRequest(url, 'DELETE', body);
}
async testConnection(): Promise<void> {
if (!this.token) throw new Error('Token is missing');
if (!this.owner) throw new Error('Owner is missing');
if (!this.repo) throw new Error('Repository is missing');
const url = `https://api.github.com/repos/${this.owner}/${this.repo}`;
async testConnection(): Promise<boolean> {
try {
const response = await this.safeRequest({
url,
method: 'GET',
headers: {
'Authorization': `token ${this.token}`,
'Accept': 'application/vnd.github.v3+json'
}
});
if (response.status !== 200) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`);
}
} catch (e) {
if (e instanceof Error) {
// Provide more helpful error messages for common issues
if (e.message.includes('NAME') || e.message.includes('resolve')) {
throw new Error(`DNS resolution failed. Please check your network connection or try restarting Obsidian. Original error: ${e.message}`);
}
if (e.message.includes('CERT') || e.message.includes('certificate')) {
throw new Error(`SSL certificate error. This may be caused by network security settings. Original error: ${e.message}`);
}
}
throw e;
}
}
async listFiles(branch: string, _path: string = ''): Promise<string[]> {
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
const response = await this.safeRequest({
url,
method: 'GET',
headers: {
'Authorization': `token ${this.token}`,
'Accept': 'application/vnd.github.v3+json'
}
});
if (response.status !== 200) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to list files: ${response.status} ${url}. Response: ${errorBody}`);
}
interface TreeItem {
path: string;
type: string;
}
interface TreeResponse {
tree: TreeItem[];
}
const data = response.json as TreeResponse;
const allFiles = data.tree
.filter(item => item.type === 'blob')
.map(item => item.path);
// Filter by rootPath if set
if (this.rootPath) {
const prefix = this.rootPath + '/';
return allFiles
.filter(file => file.startsWith(prefix))
.map(file => file.substring(prefix.length));
}
return allFiles;
}
async deleteFile(path: string, branch: string, commitMessage: string): Promise<void> {
const url = this.getApiUrl(path);
// Get current file SHA
const fileInfo = await this.getFile(path, branch);
if (!fileInfo.sha) {
throw new Error(`File not found: ${path}`);
}
const response = await this.safeRequest({
url,
method: 'DELETE',
headers: {
'Authorization': `token ${this.token}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: commitMessage,
sha: fileInfo.sha,
branch
})
});
if (response.status !== 200 && response.status !== 204) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to delete file: ${response.status} DELETE ${url}. Response: ${errorBody}`);
const url = `https://api.github.com/repos/${this.owner}/${this.repo}`;
await this.safeRequest(url, 'GET');
return true;
} catch {
return false;
}
}
async getRepoGitignores(branch: string): Promise<string[]> {
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
const response = await this.safeRequest({
url,
method: 'GET',
headers: {
'Authorization': `token ${this.token}`,
'Accept': 'application/vnd.github.v3+json'
}
});
if (response.status !== 200) {
return [];
}
interface TreeItem {
path: string;
type: string;
}
interface TreeResponse {
tree: TreeItem[];
}
const data = response.json as TreeResponse;
return data.tree
.filter(item => item.type === 'blob' && item.path.endsWith('.gitignore'))
.map(item => item.path);
const allFiles = await this.listFiles(branch);
return allFiles.filter(p => p.endsWith('.gitignore'));
}
private toBase64(str: string): string {
const bytes = new TextEncoder().encode(str);
private encodeContent(content: string): string {
const bytes = new TextEncoder().encode(content);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
for (let i = 0; i < bytes.byteLength; i++) {
const byte = bytes[i];
if (byte !== undefined) {
binary += String.fromCodePoint(byte);
}
}
return btoa(binary);
}
private fromBase64(base64: string): string {
private decodeContent(base64: string): string {
const binary = atob(base64.replace(/\s/g, ''));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
const cp = binary.codePointAt(i);
bytes[i] = cp !== undefined ? cp : 0;
}
return new TextDecoder().decode(bytes);
}

View file

@ -1,259 +1,118 @@
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem } from './git-service-base';
interface GitLabFileResponse {
content: string;
blob_id: string;
file_path: string;
}
interface ObsidianErrorResponse {
headers: Record<string, string>;
json?: unknown;
text?: string;
}
interface ObsidianResponseError {
status: number;
response?: ObsidianErrorResponse;
}
export class GitLabService implements GitServiceInterface {
private baseUrl: string;
private token: string;
private projectId: string;
private rootPath: string;
constructor(baseUrl: string, token: string, projectId: string, rootPath: string = '') {
this.updateConfig(baseUrl, token, projectId, rootPath);
}
export class GitLabService extends BaseGitService implements GitServiceInterface {
private baseUrl: string = 'https://gitlab.com';
private projectId: string = '';
updateConfig(baseUrl: string, token: string, projectId: string, rootPath: string = '') {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
this.token = token;
this.projectId = projectId;
this.rootPath = rootPath.replace(/^\/|\/$/g, '');
this.rootPath = rootPath;
}
protected addAuthHeader(headers: Record<string, string>): void {
headers['PRIVATE-TOKEN'] = this.token;
}
private getApiUrl(path: string): string {
const isAbsolute = path.startsWith('/');
const cleanPath = path.replace(/^\//, '');
const fullPath = (this.rootPath && !isAbsolute) ? `${this.rootPath}/${cleanPath}` : cleanPath;
const fullPath = this.getFullPath(path);
const encodedPath = encodeURIComponent(fullPath);
const encodedProjectId = encodeURIComponent(this.projectId);
return `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/files/${encodedPath}`;
}
/**
* Safely wraps requestUrl to handle potential throws from Obsidian and provide better error messages.
*/
private async safeRequest(params: RequestUrlParam): Promise<RequestUrlResponse> {
async getFile(path: string, branch: string): Promise<GitFile> {
try {
return await requestUrl(params);
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitLabFileResponse;
return {
content: this.decodeContent(data.content),
sha: data.last_commit_id
};
} catch (e) {
// Obsidian's requestUrl might throw an error object that contains status/response
if (typeof e === 'object' && e !== null && 'status' in e) {
const error = e as ObsidianResponseError;
const status = error.status || 0;
const responseData = error.response;
const text = responseData?.text || (responseData?.json ? JSON.stringify(responseData.json) : '');
// Re-throw as a standardized response-like object if it looks like one
if (status) {
return {
status,
headers: responseData?.headers || {},
arrayBuffer: new ArrayBuffer(0),
json: responseData?.json || {},
text: text
};
}
if (e instanceof Error && e.message.includes('404')) {
return { content: '', sha: '' };
}
throw e;
}
}
async getFile(path: string, branch: string): Promise<{ content: string; sha: string }> {
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest({
url,
method: 'GET',
headers: {
'PRIVATE-TOKEN': this.token
}
});
if (response.status === 404) {
return { content: '', sha: '' };
}
if (response.status !== 200) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to fetch file: ${response.status} from ${url}. Response: ${errorBody}`);
}
const data = (response.json as unknown) as GitLabFileResponse;
const decodedContent = this.fromBase64(data.content);
return {
content: decodedContent,
sha: data.blob_id
async pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise<string> {
const url = this.getApiUrl(path);
const body = {
branch,
content: this.encodeContent(content),
encoding: 'base64',
commit_message: message,
last_commit_id: sha
};
}
async pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise<string> {
const url = this.getApiUrl(path);
const sha = existingSha !== undefined ? existingSha : (await this.getFile(path, branch)).sha;
const method = sha ? 'PUT' : 'POST';
const response = await this.safeRequest({
url,
method,
headers: {
'PRIVATE-TOKEN': this.token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
branch,
commit_message: commitMessage,
content: this.toBase64(content),
encoding: 'base64'
})
});
if (response.status !== 200 && response.status !== 201) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to push file: ${response.status} ${method} ${url}. Response: ${errorBody}`);
}
return ((response.json as unknown) as GitLabFileResponse).file_path;
const response = await this.safeRequest(url, method, body);
const data = response.json as GitLabFileResponse;
return data.file_path;
}
async testConnection(): Promise<void> {
if (!this.token) throw new Error('Token is missing');
if (!this.projectId) throw new Error('Project ID is missing');
async listFiles(branch: string): Promise<string[]> {
const encodedProjectId = encodeURIComponent(this.projectId);
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}`;
const response = await this.safeRequest({
url,
method: 'GET',
headers: {
'PRIVATE-TOKEN': this.token
}
});
if (response.status !== 200) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`);
}
}
async listFiles(branch: string, path: string = ''): Promise<string[]> {
const encodedProjectId = encodeURIComponent(this.projectId);
const searchPath = this.rootPath || path || '';
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100${searchPath ? `&path=${encodeURIComponent(searchPath)}` : ''}`;
const response = await this.safeRequest({
url,
method: 'GET',
headers: {
'PRIVATE-TOKEN': this.token
}
});
if (response.status !== 200) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to list files: ${response.status} ${url}. Response: ${errorBody}`);
}
interface TreeItem {
path: string;
type: string;
name: string;
}
const data = response.json as TreeItem[];
const allFiles = data
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100`;
const response = await this.safeRequest(url, 'GET');
const data = response.json as GitLabTreeItem[];
return data
.filter(item => item.type === 'blob')
.map(item => item.path);
// Filter by rootPath if set
if (this.rootPath) {
const prefix = this.rootPath + '/';
return allFiles
.filter(file => file.startsWith(prefix))
.map(file => file.substring(prefix.length));
}
return allFiles;
.map(item => item.path)
.filter(p => !this.rootPath || p.startsWith(this.rootPath));
}
async deleteFile(path: string, branch: string, commitMessage: string): Promise<void> {
async deleteFile(path: string, branch: string, message: string): Promise<void> {
const url = this.getApiUrl(path);
const body = {
branch,
commit_message: message
};
const response = await this.safeRequest({
url,
method: 'DELETE',
headers: {
'PRIVATE-TOKEN': this.token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
branch,
commit_message: commitMessage
})
});
await this.safeRequest(url, 'DELETE', body);
}
if (response.status !== 200 && response.status !== 204) {
const errorBody = response.text || JSON.stringify(response.json);
throw new Error(`Failed to delete file: ${response.status} DELETE ${url}. Response: ${errorBody}`);
async testConnection(): Promise<boolean> {
try {
const encodedProjectId = encodeURIComponent(this.projectId);
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}`;
await this.safeRequest(url, 'GET');
return true;
} catch {
return false;
}
}
async getRepoGitignores(branch: string): Promise<string[]> {
const encodedProjectId = encodeURIComponent(this.projectId);
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100`;
const response = await this.safeRequest({
url,
method: 'GET',
headers: {
'PRIVATE-TOKEN': this.token
}
});
if (response.status !== 200) {
return [];
}
interface TreeItem {
path: string;
type: string;
}
const data = response.json as TreeItem[];
return data
.filter(item => item.type === 'blob' && item.path.endsWith('.gitignore'))
.map(item => item.path);
const allFiles = await this.listFiles(branch);
return allFiles.filter(p => p.endsWith('.gitignore'));
}
private toBase64(str: string): string {
const bytes = new TextEncoder().encode(str);
private encodeContent(content: string): string {
const bytes = new TextEncoder().encode(content);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
for (let i = 0; i < bytes.byteLength; i++) {
const byte = bytes[i];
if (byte !== undefined) {
binary += String.fromCodePoint(byte);
}
}
return btoa(binary);
}
private fromBase64(base64: string): string {
private decodeContent(base64: string): string {
const binary = atob(base64.replace(/\s/g, ''));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
const cp = binary.codePointAt(i);
bytes[i] = cp !== undefined ? cp : 0;
}
return new TextDecoder().decode(bytes);
}

View file

@ -60,7 +60,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.onChange((value: string) => {
this.plugin.settings.serviceType = value as GitServiceType;
void this.plugin.saveSettings();
void this.plugin.initializeGitService();
this.plugin.initializeGitService();
this.display();
}));
@ -92,7 +92,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.onChange((value) => {
this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, '');
void this.plugin.saveSettings();
void this.plugin.initializeGitService();
this.plugin.initializeGitService();
}));
new Setting(containerEl)
@ -132,7 +132,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.onChange((value) => {
this.plugin.settings.gitlabToken = value;
void this.plugin.saveSettings();
void this.plugin.initializeGitService();
this.plugin.initializeGitService();
}));
new Setting(containerEl)
@ -144,7 +144,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.onChange((value) => {
this.plugin.settings.gitlabBaseUrl = value || 'https://gitlab.com';
void this.plugin.saveSettings();
void this.plugin.initializeGitService();
this.plugin.initializeGitService();
}));
new Setting(containerEl)
@ -156,7 +156,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.onChange((value) => {
this.plugin.settings.projectId = value;
void this.plugin.saveSettings();
void this.plugin.initializeGitService();
this.plugin.initializeGitService();
}));
}
@ -170,7 +170,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.onChange((value) => {
this.plugin.settings.githubToken = value;
void this.plugin.saveSettings();
void this.plugin.initializeGitService();
this.plugin.initializeGitService();
}));
new Setting(containerEl)
@ -182,7 +182,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.onChange((value) => {
this.plugin.settings.githubOwner = value;
void this.plugin.saveSettings();
void this.plugin.initializeGitService();
this.plugin.initializeGitService();
}));
new Setting(containerEl)
@ -194,7 +194,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
.onChange((value) => {
this.plugin.settings.githubRepo = value;
void this.plugin.saveSettings();
void this.plugin.initializeGitService();
this.plugin.initializeGitService();
}));
}
}

View file

@ -1,9 +1,9 @@
import { App, Modal, ButtonComponent } from 'obsidian';
export class ConfirmModal extends Modal {
private message: string;
private onConfirm: () => void;
private onCancel?: () => void;
private readonly message: string;
private readonly onConfirm: () => void;
private readonly onCancel?: () => void;
constructor(app: App, message: string, onConfirm: () => void, onCancel?: () => void) {
super(app);

View file

@ -1,10 +1,10 @@
import { App, Modal, Setting } from 'obsidian';
export class SyncConflictModal extends Modal {
private fileName: string;
private localContent: string;
private remoteContent: string;
private onChoose: (choice: 'local' | 'remote') => void;
private readonly fileName: string;
private readonly localContent: string;
private readonly remoteContent: string;
private readonly onChoose: (choice: 'local' | 'remote') => void;
constructor(app: App, fileName: string, local: string, remote: string, onChoose: (choice: 'local' | 'remote') => void) {
super(app);

View file

@ -29,10 +29,10 @@ type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only';
export class SyncStatusView extends ItemView {
plugin: GitLabFilesPush;
private fileStatuses: Map<string, FileStatus> = new Map();
private readonly fileStatuses: Map<string, FileStatus> = new Map();
private isRefreshing = false;
private statusFilter: FilterValue = 'all';
private selectedFiles: Set<string> = new Set();
private readonly selectedFiles: Set<string> = new Set();
private lastSyncTime: number = 0;
constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush) {
@ -135,69 +135,79 @@ export class SyncStatusView extends ItemView {
}
private renderActionBar(container: HTMLElement): void {
const all = Array.from(this.fileStatuses.values());
const visible = this.statusFilter === 'all'
? all
: all.filter(s => s.status === this.statusFilter);
const selected = Array.from(this.selectedFiles)
.map(p => this.fileStatuses.get(p))
.filter(Boolean) as FileStatus[];
const canPush = selected.filter(s => s.file && (s.status === 'modified' || s.status === 'unsynced')).length;
const canPull = selected.filter(s => s.status === 'modified' || s.status === 'remote-only').length;
const canDelete = selected.filter(s => s.file || s.status === 'remote-only').length;
const allSelected = visible.length > 0 && visible.every(s => this.selectedFiles.has(s.path));
const { visible, canPush, canPull, canDelete, allSelected } = this.getActionBarState();
const bar = container.createDiv({ cls: 'ssv-action-bar' });
const refreshBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' });
refreshBtn.createSpan({ text: '↻' });
refreshBtn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' });
setTooltip(refreshBtn, 'Refresh all statuses');
refreshBtn.addEventListener('click', () => void this.refreshAllStatuses());
this.renderRefreshButton(bar);
if (this.fileStatuses.size > 0) {
bar.createDiv({ cls: 'ssv-bar-spacer' });
const selectRow = bar.createDiv({ cls: 'ssv-select-row' });
const cb = selectRow.createEl('input', { type: 'checkbox' });
cb.checked = allSelected;
cb.indeterminate = this.selectedFiles.size > 0 && !allSelected;
selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' });
cb.addEventListener('change', () => {
if (cb.checked) {
for (const s of visible) this.selectedFiles.add(s.path);
} else {
this.selectedFiles.clear();
}
this.renderView();
});
const pushBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-push' });
pushBtn.createSpan({ text: '↑' });
pushBtn.createSpan({ cls: 'ssv-btn-label', text: ` Push (${canPush})` });
pushBtn.disabled = canPush === 0;
setTooltip(pushBtn, `Push ${canPush} files`);
pushBtn.addEventListener('click', () => void this.pushSelected());
const pullBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-pull' });
pullBtn.createSpan({ text: '↓' });
pullBtn.createSpan({ cls: 'ssv-btn-label', text: ` Pull (${canPull})` });
pullBtn.disabled = canPull === 0;
setTooltip(pullBtn, `Pull ${canPull} files`);
pullBtn.addEventListener('click', () => void this.pullSelected());
const delBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-delete' });
delBtn.createSpan({ text: '✕' });
delBtn.createSpan({ cls: 'ssv-btn-label', text: ` Delete (${canDelete})` });
delBtn.disabled = canDelete === 0;
setTooltip(delBtn, `Delete ${canDelete} files`);
delBtn.addEventListener('click', () => void this.deleteSelected());
this.renderSelectAllRow(bar, allSelected, visible);
this.renderActionButtons(bar, canPush, canPull, canDelete);
}
}
private getActionBarState() {
const all = Array.from(this.fileStatuses.values());
const visible = this.statusFilter === 'all' ? all : all.filter(s => s.status === this.statusFilter);
const selected = Array.from(this.selectedFiles).map(p => this.fileStatuses.get(p)).filter(Boolean) as FileStatus[];
return {
visible,
canPush: selected.filter(s => s.file && (s.status === 'modified' || s.status === 'unsynced')).length,
canPull: selected.filter(s => s.status === 'modified' || s.status === 'remote-only').length,
canDelete: selected.filter(s => s.file || s.status === 'remote-only').length,
allSelected: visible.length > 0 && visible.every(s => this.selectedFiles.has(s.path))
};
}
private renderRefreshButton(bar: HTMLElement): void {
const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' });
btn.createSpan({ text: '↻' });
btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' });
setTooltip(btn, 'Refresh all statuses');
btn.addEventListener('click', () => void this.refreshAllStatuses());
}
private renderSelectAllRow(bar: HTMLElement, allSelected: boolean, visible: FileStatus[]): void {
const selectRow = bar.createDiv({ cls: 'ssv-select-row' });
const cb = selectRow.createEl('input', { type: 'checkbox' });
cb.checked = allSelected;
cb.indeterminate = this.selectedFiles.size > 0 && !allSelected;
selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' });
cb.addEventListener('change', () => {
if (cb.checked) {
for (const s of visible) this.selectedFiles.add(s.path);
} else {
this.selectedFiles.clear();
}
this.renderView();
});
}
private renderActionButtons(bar: HTMLElement, canPush: number, canPull: number, canDelete: number): void {
const pushBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-push' });
pushBtn.createSpan({ text: '↑' });
pushBtn.createSpan({ cls: 'ssv-btn-label', text: ` Push (${canPush})` });
pushBtn.disabled = canPush === 0;
setTooltip(pushBtn, `Push ${canPush} files`);
pushBtn.addEventListener('click', () => void this.pushSelected());
const pullBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-pull' });
pullBtn.createSpan({ text: '↓' });
pullBtn.createSpan({ cls: 'ssv-btn-label', text: ` Pull (${canPull})` });
pullBtn.disabled = canPull === 0;
setTooltip(pullBtn, `Pull ${canPull} files`);
pullBtn.addEventListener('click', () => void this.pullSelected());
const delBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-delete' });
delBtn.createSpan({ text: '✕' });
delBtn.createSpan({ cls: 'ssv-btn-label', text: ` Delete (${canDelete})` });
delBtn.disabled = canDelete === 0;
setTooltip(delBtn, `Delete ${canDelete} files`);
delBtn.addEventListener('click', () => void this.deleteSelected());
}
private renderFileList(container: HTMLElement): void {
const all = Array.from(this.fileStatuses.values());
const statuses = this.statusFilter === 'all'
@ -213,12 +223,21 @@ export class SyncStatusView extends ItemView {
private renderFileItem(container: HTMLElement, fileStatus: FileStatus): void {
const { icon, label, iconCls, badgeCls, fileCls } = this.statusMeta(fileStatus.status);
const fileEl = container.createDiv({ cls: `ssv-file ${fileCls}` });
// ── Main row ──
const row = fileEl.createDiv({ cls: 'ssv-file-row' });
this.renderFileCheckbox(row, fileStatus);
row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon });
row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path });
row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label });
if (fileStatus.status !== 'synced' && fileStatus.status !== 'checking') {
this.renderFileActions(fileEl, fileStatus);
}
}
private renderFileCheckbox(row: HTMLElement, fileStatus: FileStatus): void {
const cb = row.createEl('input', { type: 'checkbox', cls: 'ssv-file-checkbox' });
cb.checked = this.selectedFiles.has(fileStatus.path);
cb.addEventListener('change', () => {
@ -229,76 +248,67 @@ export class SyncStatusView extends ItemView {
}
this.renderView();
});
}
row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon });
row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path });
row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label });
if (fileStatus.status === 'synced' || fileStatus.status === 'checking') return;
// ── Action row ──
private renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus): void {
const actions = fileEl.createDiv({ cls: 'ssv-file-actions' });
// Diff toggle (modified only)
if (fileStatus.status === 'modified' && fileStatus.diff) {
const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
diffBtn.createSpan({ text: '≡' });
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
const diffEl = this.renderDiffPanel(fileEl, fileStatus);
setTooltip(diffBtn, 'Toggle diff view');
diffBtn.addEventListener('click', () => {
const open = diffEl.hasClass('visible');
diffEl.toggleClass('visible', !open);
btnLabel.setText(open ? ' Diff' : ' Hide');
const firstChild = diffBtn.firstChild;
if (firstChild instanceof HTMLElement || firstChild instanceof Text) {
firstChild.textContent = open ? '≡' : '▴';
}
});
this.renderDiffToggleButton(actions, fileEl, fileStatus);
}
// Push
if ((fileStatus.status === 'modified' || fileStatus.status === 'unsynced') && fileStatus.file) {
const pushBtn = actions.createEl('button', { cls: 'ssv-action-btn push' });
pushBtn.createSpan({ text: '↑' });
pushBtn.createSpan({ cls: 'ssv-btn-label', text: ' Push' });
setTooltip(pushBtn, 'Push to remote');
pushBtn.addEventListener('click', () => void this.runSingleFile(fileStatus, 'push'));
this.renderActionButton(actions, '↑', ' Push', 'Push to remote', () => void this.runSingleFile(fileStatus, 'push'), 'push');
}
// Pull
if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') {
const pullBtn = actions.createEl('button', { cls: 'ssv-action-btn pull' });
pullBtn.createSpan({ text: '↓' });
pullBtn.createSpan({ cls: 'ssv-btn-label', text: ' Pull' });
setTooltip(pullBtn, 'Pull from remote');
pullBtn.addEventListener('click', () => void this.runSingleFile(fileStatus, 'pull'));
this.renderActionButton(actions, '↓', ' Pull', 'Pull from remote', () => void this.runSingleFile(fileStatus, 'pull'), 'pull');
}
// Remove local
if (fileStatus.status === 'unsynced' && fileStatus.file) {
const removeBtn = actions.createEl('button', { cls: 'ssv-action-btn danger' });
removeBtn.createSpan({ text: '✕' });
removeBtn.createSpan({ cls: 'ssv-btn-label', text: ' Remove' });
setTooltip(removeBtn, 'Delete local file');
removeBtn.addEventListener('click', () => {
void (async () => {
const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`);
if (!confirmed) return;
try {
if (fileStatus.file) {
await this.app.fileManager.trashFile(fileStatus.file);
} else {
await this.app.vault.adapter.remove(fileStatus.path);
}
new Notice(`Deleted ${fileStatus.path}`);
this.fileStatuses.delete(fileStatus.path);
this.renderView();
} catch (e) {
new Notice(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`);
}
})();
});
this.renderActionButton(actions, '✕', ' Remove', 'Delete local file', () => void this.handleLocalDelete(fileStatus), 'danger');
}
}
private renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void {
const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
diffBtn.createSpan({ text: '≡' });
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
const diffEl = this.renderDiffPanel(fileEl, fileStatus);
setTooltip(diffBtn, 'Toggle diff view');
diffBtn.addEventListener('click', () => {
const open = diffEl.hasClass('visible');
diffEl.toggleClass('visible', !open);
btnLabel.setText(open ? ' Diff' : ' Hide');
const firstChild = diffBtn.firstChild;
if (firstChild instanceof HTMLElement || firstChild instanceof Text) {
firstChild.textContent = open ? '≡' : '▴';
}
});
}
private renderActionButton(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void {
const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` });
btn.createSpan({ text: icon });
btn.createSpan({ cls: 'ssv-btn-label', text: label });
setTooltip(btn, tooltip);
btn.addEventListener('click', onClick);
}
private async handleLocalDelete(fileStatus: FileStatus): Promise<void> {
const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`);
if (!confirmed) return;
try {
if (fileStatus.file) {
await this.app.fileManager.trashFile(fileStatus.file);
} else {
await this.app.vault.adapter.remove(fileStatus.path);
}
new Notice(`Deleted ${fileStatus.path}`);
this.fileStatuses.delete(fileStatus.path);
this.renderView();
} catch (e) {
new Notice(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`);
}
}
@ -387,7 +397,7 @@ export class SyncStatusView extends ItemView {
private renderDiffCell(grid: HTMLElement, side: DiffSide): void {
const cell = grid.createDiv({ cls: `ssv-diff-cell ${side.type}` });
cell.createSpan({ cls: 'ssv-diff-ln' }).textContent = side.lineNum !== null ? String(side.lineNum) : '';
cell.createSpan({ cls: 'ssv-diff-ln' }).textContent = side.lineNum === null ? '' : String(side.lineNum);
if (side.content !== null) {
cell.createSpan({ cls: 'ssv-diff-code' }).textContent = side.content;
}
@ -464,12 +474,12 @@ export class SyncStatusView extends ItemView {
const rIdx = removedIdxs[x];
const aIdx = addedIdxs[x];
rows.push({
left: rIdx !== undefined
? { lineNum: rIdx + 1, content: L[rIdx] ?? null, type: 'removed' }
: { lineNum: null, content: null, type: 'empty' },
right: aIdx !== undefined
? { lineNum: aIdx + 1, content: R[aIdx] ?? null, type: 'added' }
: { lineNum: null, content: null, type: 'empty' },
left: rIdx === undefined
? { lineNum: null, content: null, type: 'empty' }
: { lineNum: rIdx + 1, content: L[rIdx] ?? null, type: 'removed' },
right: aIdx === undefined
? { lineNum: null, content: null, type: 'empty' }
: { lineNum: aIdx + 1, content: R[aIdx] ?? null, type: 'added' },
});
}
}
@ -501,86 +511,22 @@ export class SyncStatusView extends ItemView {
this.isRefreshing = true;
this.fileStatuses.clear();
// Show progress bar inside the list container
const container = this.containerEl.children[1];
if (container) {
const listEl = container.querySelector('.ssv-list');
if (listEl) {
listEl.empty();
const prog = listEl.createDiv({ cls: 'ssv-progress' });
prog.createDiv({ cls: 'ssv-progress-text', text: 'Checking files…' });
const bar = prog.createDiv({ cls: 'ssv-progress-bar' });
const fill = bar.createDiv({ cls: 'ssv-progress-fill' });
fill.setAttr('style', 'width: 0%');
}
}
this.showProgressIndicator();
try {
const allFiles = this.app.vault.getFiles();
let files = this.plugin.filterFilesByVaultFolder(allFiles);
let remoteFiles = await this.plugin.gitService.listFiles(this.plugin.settings.branch);
await this.plugin.gitignoreManager.loadGitignores();
remoteFiles = remoteFiles.filter(p => !this.plugin.gitignoreManager.isIgnored(p));
files = files.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path));
const localFilePaths = new Set(files.map(f => f.path));
const allLocalFileMap = new Map<string, TFile>(allFiles.map(f => [f.path, f]));
for (const file of files) {
this.fileStatuses.set(file.path, { file, path: file.path, status: 'checking' });
}
const extraFilesToCheck: Array<TFile | string> = [];
for (const remotePath of remoteFiles) {
if (!localFilePaths.has(remotePath)) {
let localFile = allLocalFileMap.get(remotePath);
if (!localFile) {
const abs = this.app.vault.getAbstractFileByPath(remotePath);
if (abs instanceof TFile) localFile = abs;
}
if (localFile) {
this.fileStatuses.set(remotePath, { file: localFile, path: remotePath, status: 'checking' });
extraFilesToCheck.push(localFile);
} else if (await this.app.vault.adapter.exists(remotePath)) {
this.fileStatuses.set(remotePath, { path: remotePath, status: 'checking' });
extraFilesToCheck.push(remotePath);
} else {
this.fileStatuses.set(remotePath, { path: remotePath, status: 'remote-only' });
}
}
}
const files = await this.discoverFiles();
this.initializeFileStatuses(files.local);
const extra = await this.identifyExtraFiles(files.remote, files.localMap, files.allMap);
this.addExtraToStatuses(extra);
this.renderView();
let filesToCheck: Array<TFile | string> = [...files, ...extraFilesToCheck];
filesToCheck = filesToCheck.filter(f => {
const p = typeof f === 'string' ? f : f.path;
return !this.plugin.gitignoreManager.isIgnored(p);
});
const total = filesToCheck.length;
let checked = 0;
for (const file of filesToCheck) {
await this.refreshFileStatus(file);
checked++;
const c = this.containerEl.children[1];
if (c) {
const fill = c.querySelector('.ssv-progress-fill');
const text = c.querySelector('.ssv-progress-text');
if (fill && text) {
const pct = Math.round((checked / total) * 100);
fill.setAttr('style', `width: ${pct}%`);
text.textContent = `Checking files… ${checked}/${total} (${pct}%)`;
}
}
}
const filesToCheck = this.getCheckableFiles(files.local, extra);
await this.performStatusCheck(filesToCheck);
this.lastSyncTime = Date.now();
this.renderView();
new Notice(`Checked ${files.length} local + ${remoteFiles.length} remote files`);
new Notice(`Checked ${files.local.length} local + ${files.remote.length} remote files`);
} catch (e) {
new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`);
} finally {
@ -588,6 +534,103 @@ export class SyncStatusView extends ItemView {
}
}
private showProgressIndicator(): void {
const container = this.containerEl.children[1];
if (!container) return;
const listEl = container.querySelector('.ssv-list');
if (!listEl) return;
listEl.empty();
const prog = listEl.createDiv({ cls: 'ssv-progress' });
prog.createDiv({ cls: 'ssv-progress-text', text: 'Checking files…' });
const bar = prog.createDiv({ cls: 'ssv-progress-bar' });
const fill = bar.createDiv({ cls: 'ssv-progress-fill' });
fill.setAttr('style', 'width: 0%');
}
private async discoverFiles() {
const allFiles = this.app.vault.getFiles();
let local = this.plugin.filterFilesByVaultFolder(allFiles);
let remote = await this.plugin.gitService.listFiles(this.plugin.settings.branch);
await this.plugin.gitignoreManager.loadGitignores();
remote = remote.filter(p => !this.plugin.gitignoreManager.isIgnored(p));
local = local.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path));
return {
local,
remote,
localMap: new Set(local.map(f => f.path)),
allMap: new Map<string, TFile>(allFiles.map(f => [f.path, f]))
};
}
private initializeFileStatuses(localFiles: TFile[]): void {
for (const file of localFiles) {
this.fileStatuses.set(file.path, { file, path: file.path, status: 'checking' });
}
}
private async identifyExtraFiles(remoteFiles: string[], localFilePaths: Set<string>, allLocalFileMap: Map<string, TFile>) {
const extra: Array<TFile | string> = [];
for (const remotePath of remoteFiles) {
if (localFilePaths.has(remotePath)) continue;
let localFile = allLocalFileMap.get(remotePath);
if (!localFile) {
const abs = this.app.vault.getAbstractFileByPath(remotePath);
if (abs instanceof TFile) localFile = abs;
}
if (localFile) {
extra.push(localFile);
} else if (await this.app.vault.adapter.exists(remotePath)) {
extra.push(remotePath);
} else {
this.fileStatuses.set(remotePath, { path: remotePath, status: 'remote-only' });
}
}
return extra;
}
private addExtraToStatuses(extra: Array<TFile | string>): void {
for (const item of extra) {
const path = typeof item === 'string' ? item : item.path;
const file = typeof item === 'string' ? undefined : item;
this.fileStatuses.set(path, { file, path, status: 'checking' });
}
}
private getCheckableFiles(local: TFile[], extra: Array<TFile | string>) {
const combined: Array<TFile | string> = [...local, ...extra];
return combined.filter(f => {
const p = typeof f === 'string' ? f : f.path;
return !this.plugin.gitignoreManager.isIgnored(p);
});
}
private async performStatusCheck(filesToCheck: Array<TFile | string>): Promise<void> {
const total = filesToCheck.length;
for (let i = 0; i < total; i++) {
const file = filesToCheck[i];
if (file) {
await this.refreshFileStatus(file);
}
this.updateRefreshProgress(i + 1, total);
}
}
private updateRefreshProgress(current: number, total: number): void {
const c = this.containerEl.children[1];
if (!c) return;
const fill = c.querySelector('.ssv-progress-fill');
const text = c.querySelector('.ssv-progress-text');
if (fill && text) {
const pct = Math.round((current / total) * 100);
fill.setAttr('style', `width: ${pct}%`);
text.textContent = `Checking files… ${current}/${total} (${pct}%)`;
}
}
private async refreshFileStatus(fileOrPath: TFile | string): Promise<void> {
try {
const isStr = typeof fileOrPath === 'string';
@ -705,28 +748,51 @@ export class SyncStatusView extends ItemView {
}
async deleteSelected(): Promise<void> {
if (this.selectedFiles.size === 0) { new Notice('No files selected'); return; }
const targets = this.getSelectedTargets();
if (targets.length === 0) return;
const targets = Array.from(this.selectedFiles)
.map(p => this.fileStatuses.get(p))
.filter(Boolean) as FileStatus[];
const local = targets.filter(s => s.status !== 'remote-only');
const remote = targets.filter(s => s.status === 'remote-only');
const { local, remote } = this.partitionTargets(targets);
if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; }
let msg = '';
if (local.length > 0 && remote.length > 0) msg = `Delete ${local.length} local + ${remote.length} remote file(s)? Cannot be undone.`;
else if (local.length > 0) msg = `Delete ${local.length} local file(s)? Cannot be undone.`;
else msg = `Delete ${remote.length} remote file(s)? Cannot be undone.`;
if (!await this.showConfirmDialog(msg)) return;
if (!await this.confirmDeletion(local.length, remote.length)) return;
const total = local.length + remote.length;
const prog = new Notice(`Deleting 0/${total} files…`, 0);
const errors: string[] = [];
let cur = 0;
await this.performLocalDeletion(local, total, prog, errors);
await this.performRemoteDeletion(remote, total, local.length, prog, errors);
prog.hide();
this.notifyDeletionResults(total, errors.length);
this.renderView();
}
private getSelectedTargets(): FileStatus[] {
if (this.selectedFiles.size === 0) { new Notice('No files selected'); return []; }
return Array.from(this.selectedFiles)
.map(p => this.fileStatuses.get(p))
.filter(Boolean) as FileStatus[];
}
private partitionTargets(targets: FileStatus[]) {
return {
local: targets.filter(s => s.status !== 'remote-only'),
remote: targets.filter(s => s.status === 'remote-only')
};
}
private async confirmDeletion(localCount: number, remoteCount: number): Promise<boolean> {
let msg = '';
if (localCount > 0 && remoteCount > 0) msg = `Delete ${localCount} local + ${remoteCount} remote file(s)? Cannot be undone.`;
else if (localCount > 0) msg = `Delete ${localCount} local file(s)? Cannot be undone.`;
else msg = `Delete ${remoteCount} remote file(s)? Cannot be undone.`;
return await this.showConfirmDialog(msg);
}
private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: string[]): Promise<void> {
let cur = 0;
for (const s of local) {
cur++;
prog.setMessage(`Deleting local ${cur}/${total}: ${s.path}`);
@ -737,7 +803,10 @@ export class SyncStatusView extends ItemView {
this.selectedFiles.delete(s.path);
} catch { errors.push(s.path); }
}
}
private async performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: string[]): Promise<void> {
let cur = localCount;
for (const s of remote) {
cur++;
prog.setMessage(`Deleting remote ${cur}/${total}: ${s.path}`);
@ -747,13 +816,13 @@ export class SyncStatusView extends ItemView {
this.selectedFiles.delete(s.path);
} catch { errors.push(s.path); }
}
}
prog.hide();
new Notice(errors.length > 0
? `Deleted ${total - errors.length}/${total}. ${errors.length} failed.`
private notifyDeletionResults(total: number, errorCount: number): void {
new Notice(errorCount > 0
? `Deleted ${total - errorCount}/${total}. ${errorCount} failed.`
: `Deleted ${total} files`
);
this.renderView();
}
onClose(): Promise<void> {

View file

@ -55,6 +55,7 @@ describe('SyncManager', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSettings.syncMetadata = {};
// Default: file exists in vault
vi.spyOn(mockApp.vault, 'getFileByPath').mockReturnValue(new TFile());
manager = new SyncManager(mockApp, mockGitLab, mockSettings);
@ -294,7 +295,12 @@ describe('SyncManager', () => {
vi.spyOn(mockApp.vault, 'read').mockResolvedValue('c');
vi.spyOn(mockGitLab, 'pushFile').mockRejectedValue(new Error('Rename failed'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await manager.pushFile(mockFile);
expect(consoleSpy).toHaveBeenCalled();
// Verify metadata wasn't updated
expect(mockSettings.syncMetadata[oldPath]).toBeDefined();
expect(mockSettings.syncMetadata[newPath]).toBeUndefined();
});
});
@ -311,8 +317,9 @@ describe('SyncManager', () => {
const mockFile = Object.assign(new TFile(), { path: 'fail.md', name: 'fail.md' });
vi.mocked(mockGitLab.getFile).mockRejectedValue(new Error('Network error'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await manager.pullFile(mockFile);
// Catch block covered
expect(consoleSpy).toHaveBeenCalled();
});
});
});

View file

@ -10,7 +10,8 @@ describe('GitHubService', () => {
beforeEach(() => {
vi.clearAllMocks();
service = new GitHubService(token, owner, repo);
service = new GitHubService();
service.updateConfig(token, owner, repo);
});
describe('getFile', () => {
@ -49,13 +50,12 @@ describe('GitHubService', () => {
});
describe('pushFile', () => {
it('should push new file correctly (no sha provided, remote 404)', async () => {
it('should push new file correctly (no sha provided)', async () => {
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 404 } as unknown as RequestUrlResponse) // getFile check
.mockResolvedValueOnce({
status: 201,
json: { content: { path: 'new.md' } }
} as unknown as RequestUrlResponse); // push
} as unknown as RequestUrlResponse);
const result = await service.pushFile('new.md', 'new content', 'main', 'create');

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GitLabService } from '../../src/services/gitlab-service';
import { requestUrl, RequestUrlResponse } from 'obsidian';
import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian';
describe('GitLabService', () => {
let service: GitLabService;
@ -10,30 +10,32 @@ describe('GitLabService', () => {
beforeEach(() => {
vi.clearAllMocks();
service = new GitLabService(baseUrl, token, projectId, '');
service = new GitLabService();
service.updateConfig(baseUrl, token, projectId, '');
});
describe('getFile', () => {
it('should fetch and decode file content correctly', async () => {
const mockResponse = {
const mockResponse = {
status: 200,
json: {
content: btoa(encodeURIComponent('hello world').replace(/%([0-9A-F]{2})/g, (_match, p1: string) => {
return String.fromCharCode(parseInt(p1, 16));
})),
blob_id: 'test-sha'
content: btoa('hello world'),
last_commit_id: 'test-commit-id'
}
};
vi.mocked(requestUrl).mockResolvedValue(mockResponse as unknown as RequestUrlResponse);
} as unknown as RequestUrlResponse;
vi.mocked(requestUrl).mockResolvedValue(mockResponse);
const result = await service.getFile('test.md', 'main');
expect(result.content).toBe('hello world');
expect(result.sha).toBe('test-sha');
expect(requestUrl).toHaveBeenCalledWith(expect.objectContaining({
method: 'GET',
headers: { 'PRIVATE-TOKEN': token }
}));
expect(result.sha).toBe('test-commit-id');
const calls = vi.mocked(requestUrl).mock.calls;
const lastCallParams = calls[0];
if (!lastCallParams) throw new Error('requestUrl was not called');
const lastCall = lastCallParams[0] as RequestUrlParam;
expect(lastCall.method).toBe('GET');
expect(lastCall.headers).toMatchObject({ 'PRIVATE-TOKEN': token });
});
it('should handle 404 correctly in getFile and return empty content', async () => {
@ -43,52 +45,47 @@ describe('GitLabService', () => {
expect(result.sha).toBe('');
});
it('should return blob_id as sha', async () => {
const mockResponse: unknown = {
it('should return last_commit_id as sha', async () => {
const mockResponse = {
status: 200,
json: {
content: btoa('test content'),
blob_id: 'test-blob-id'
last_commit_id: 'test-last-commit-id'
}
};
vi.mocked(requestUrl).mockResolvedValue(mockResponse as RequestUrlResponse);
} as unknown as RequestUrlResponse;
vi.mocked(requestUrl).mockResolvedValue(mockResponse);
const result = await service.getFile('test.md', 'main');
expect(result.sha).toBe('test-blob-id');
expect(result.sha).toBe('test-last-commit-id');
});
});
describe('pushFile', () => {
it('should push file content correctly (POST for new file)', async () => {
// Mock getFile failing (404)
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 404 } as unknown as RequestUrlResponse) // getFile check
.mockResolvedValueOnce({ status: 201, json: { file_path: 'test.md' } } as unknown as RequestUrlResponse); // push
vi.mocked(requestUrl).mockResolvedValue({ status: 201, json: { file_path: 'test.md' } } as unknown as RequestUrlResponse);
const result = await service.pushFile('test.md', 'new content', 'main', 'initial commit');
expect(result).toBe('test.md');
expect(requestUrl).toHaveBeenLastCalledWith(expect.objectContaining({
method: 'POST',
body: expect.stringContaining(btoa('new content')) as unknown as string
}));
const calls = vi.mocked(requestUrl).mock.calls;
const lastCallParams = calls[calls.length - 1];
if (!lastCallParams) throw new Error('requestUrl was not called');
const lastCall = lastCallParams[0] as RequestUrlParam;
expect(lastCall.method).toBe('POST');
expect(lastCall.body).toContain(btoa('new content'));
});
it('should push file content correctly (PUT for existing file)', async () => {
// Mock getFile succeeding
vi.mocked(requestUrl)
.mockResolvedValueOnce({
status: 200,
json: { content: btoa('old'), blob_id: 'old-sha' }
} as unknown as RequestUrlResponse) // getFile check
.mockResolvedValueOnce({ status: 200, json: { file_path: 'test.md' } } as unknown as RequestUrlResponse); // push
vi.mocked(requestUrl).mockResolvedValue({ status: 200, json: { file_path: 'test.md' } } as unknown as RequestUrlResponse);
const result = await service.pushFile('test.md', 'updated content', 'main', 'update');
const result = await service.pushFile('test.md', 'updated content', 'main', 'update', 'old-sha');
expect(result).toBe('test.md');
expect(requestUrl).toHaveBeenLastCalledWith(expect.objectContaining({
method: 'PUT',
body: expect.stringContaining(btoa('updated content')) as unknown as string
}));
const calls = vi.mocked(requestUrl).mock.calls;
const lastCallParams = calls[calls.length - 1];
if (!lastCallParams) throw new Error('requestUrl was not called');
const lastCall = lastCallParams[0] as RequestUrlParam;
expect(lastCall.method).toBe('PUT');
expect(lastCall.body).toContain(btoa('updated content'));
});
});
});