mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
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:
parent
f33ef3e3fd
commit
655cd69252
15 changed files with 725 additions and 833 deletions
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
src/main.ts
42
src/main.ts
|
|
@ -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) {
|
||||
|
|
|
|||
97
src/services/git-service-base.ts
Normal file
97
src/services/git-service-base.ts
Normal 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[]>;
|
||||
}
|
||||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue