From 06fb8dfa895c37f21d5316abaf2d2e1b8b4519c7 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 25 Apr 2026 20:47:06 +0000 Subject: [PATCH 1/7] ci: optimize workflow to reduce redundancy and refine permissions --- .github/workflows/ci.yml | 58 +++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e3a101..d8d8517 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,38 @@ jobs: - 'styles.css' - 'main.js' + build: + name: Build + needs: filter + if: needs.filter.outputs.code == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npm run build + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: build-output + path: | + main.js + manifest.json + styles.css + retention-days: 1 + lint: name: Lint needs: filter if: needs.filter.outputs.code == 'true' runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -56,7 +83,8 @@ jobs: name: Test needs: filter if: needs.filter.outputs.code == 'true' - runs-on: ubuntu-latest + permissions: + contents: read outputs: version: ${{ steps.version.outputs.version }} steps: @@ -80,6 +108,8 @@ jobs: needs: [filter, test] if: (needs.filter.outputs.code == 'true') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') runs-on: ubuntu-latest + permissions: + contents: read continue-on-error: true steps: - uses: actions/checkout@v6 @@ -104,17 +134,17 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - needs: [filter, test] + needs: [filter, test, build] # Run on PRs or feature branches if code changed if: needs.filter.outputs.code == 'true' && (github.event_name == 'pull_request' || (github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master')) steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - name: Download build artifact + uses: actions/download-artifact@v8 with: - node-version: '22' - cache: 'npm' - - run: npm ci - - run: npm run build + name: build-output + path: . + - name: Create plugin package - name: Create plugin package run: | VERSION=${{ needs.test.outputs.version }} @@ -125,15 +155,15 @@ jobs: echo "ZIP_NAME=$ZIP_NAME" >> $GITHUB_ENV - uses: actions/upload-artifact@v7 with: - name: plugin-build-${{ needs.test.outputs.version }}-${{ github.sha }} + name: plugin-build-${{ needs.test.outputs.version }}-${{ github.run_id }} path: ${{ env.ZIP_NAME }} retention-days: 7 release: name: Build and Release runs-on: ubuntu-latest - needs: [filter, lint, test, sonar] - # Only run on push to main/master if code changed + needs: [filter, lint, test, sonar, build] + # Only run on push to main or master if code changed if: needs.filter.outputs.code == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') permissions: contents: write @@ -149,7 +179,11 @@ jobs: node-version: '22' cache: 'npm' - run: npm ci - - run: npm run build + - name: Download build artifact + uses: actions/download-artifact@v8 + with: + name: build-output + path: . - env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} run: npx semantic-release @@ -158,7 +192,7 @@ jobs: name: CI/CD Status runs-on: ubuntu-latest if: always() - needs: [filter, lint, test, sonar, artifact, release] + needs: [filter, build, lint, test, sonar, artifact, release] steps: - name: Check for failures if: | From 71d3ac052998c361bc503936c1cd2ce32bbc3de4 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 25 Apr 2026 20:49:46 +0000 Subject: [PATCH 2/7] ci: fix syntax errors in workflow file --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8d8517..929631e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,7 @@ jobs: name: Test needs: filter if: needs.filter.outputs.code == 'true' + runs-on: ubuntu-latest permissions: contents: read outputs: @@ -144,7 +145,6 @@ jobs: with: name: build-output path: . - - name: Create plugin package - name: Create plugin package run: | VERSION=${{ needs.test.outputs.version }} From bf9850c7fb9741f8fb130672e5700a0a5161a048 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 25 Apr 2026 20:57:08 +0000 Subject: [PATCH 3/7] docs: resolve SonarCloud issues and refactor for complexity --- .github/workflows/ci.yml | 65 ++--- src/logic/gitignore-manager.ts | 74 ++--- src/logic/sync-manager.ts | 115 ++++---- src/main.ts | 34 +-- src/services/github-service.ts | 6 +- src/services/gitlab-service.ts | 9 +- src/settings.ts | 16 +- src/ui/ConfirmModal.ts | 6 +- src/ui/SyncConflictModal.ts | 8 +- src/ui/SyncStatusView.ts | 479 ++++++++++++++++++------------- tests/logic/sync-manager.test.ts | 9 +- 11 files changed, 442 insertions(+), 379 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 929631e..95c528f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,38 +38,11 @@ jobs: - 'styles.css' - 'main.js' - build: - name: Build - needs: filter - if: needs.filter.outputs.code == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: '22' - cache: 'npm' - - run: npm ci - - run: npm run build - - name: Upload build artifact - uses: actions/upload-artifact@v7 - with: - name: build-output - path: | - main.js - manifest.json - styles.css - retention-days: 1 - lint: name: Lint needs: filter if: needs.filter.outputs.code == 'true' runs-on: ubuntu-latest - permissions: - contents: read steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -84,8 +57,6 @@ jobs: needs: filter if: needs.filter.outputs.code == 'true' runs-on: ubuntu-latest - permissions: - contents: read outputs: version: ${{ steps.version.outputs.version }} steps: @@ -109,8 +80,6 @@ jobs: needs: [filter, test] if: (needs.filter.outputs.code == 'true') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') runs-on: ubuntu-latest - permissions: - contents: read continue-on-error: true steps: - uses: actions/checkout@v6 @@ -129,22 +98,32 @@ 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 runs-on: ubuntu-latest permissions: contents: read - needs: [filter, test, build] + needs: [filter, test] # Run on PRs or feature branches if code changed if: needs.filter.outputs.code == 'true' && (github.event_name == 'pull_request' || (github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master')) steps: - uses: actions/checkout@v6 - - name: Download build artifact - uses: actions/download-artifact@v8 + - uses: actions/setup-node@v6 with: - name: build-output - path: . + node-version: '22' + cache: 'npm' + - run: npm ci + - run: npm run build - name: Create plugin package run: | VERSION=${{ needs.test.outputs.version }} @@ -155,15 +134,15 @@ jobs: echo "ZIP_NAME=$ZIP_NAME" >> $GITHUB_ENV - uses: actions/upload-artifact@v7 with: - name: plugin-build-${{ needs.test.outputs.version }}-${{ github.run_id }} + name: plugin-build-${{ needs.test.outputs.version }}-${{ github.sha }} path: ${{ env.ZIP_NAME }} retention-days: 7 release: name: Build and Release runs-on: ubuntu-latest - needs: [filter, lint, test, sonar, build] - # Only run on push to main or master if code changed + needs: [filter, lint, test, sonar] + # Only run on push to main/master if code changed if: needs.filter.outputs.code == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') permissions: contents: write @@ -179,11 +158,7 @@ jobs: node-version: '22' cache: 'npm' - run: npm ci - - name: Download build artifact - uses: actions/download-artifact@v8 - with: - name: build-output - path: . + - run: npm run build - env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} run: npx semantic-release @@ -192,7 +167,7 @@ jobs: name: CI/CD Status runs-on: ubuntu-latest if: always() - needs: [filter, build, lint, test, sonar, artifact, release] + needs: [filter, lint, test, sonar, artifact, release] steps: - name: Check for failures if: | diff --git a/src/logic/gitignore-manager.ts b/src/logic/gitignore-manager.ts index 16022d3..a7e7cfa 100644 --- a/src/logic/gitignore-manager.ts +++ b/src/logic/gitignore-manager.ts @@ -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 = new Map(); + private readonly ignoreMap: Map = new Map(); constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string) { this.app = app; @@ -38,45 +38,49 @@ 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; + const content = await this.getGitignoreContent(fullGitignorePath); - // 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); + if (content) { + const ig = ignore().add(content); + this.ignoreMap.set(dirPath, ig); } + } + } - // 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); - } - } + private async getGitignoreContent(fullGitignorePath: string): Promise { + let content: string | undefined; - // 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 + // 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); } + } - if (content) { - const ig = ignore().add(content); - this.ignoreMap.set(dirPath, ig); + // 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; } /** diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index f284321..d62a543 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -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,47 @@ 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 { + if (isString) { + return await this.app.vault.adapter.exists(path); + } + return !!this.app.vault.getFileByPath(path); + } + + private async getFileContent(fileOrPath: TFile | string): Promise { + 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); + 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 }; + } } diff --git a/src/main.ts b/src/main.ts index e50c358..5c79b80 100644 --- a/src/main.ts +++ b/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); } } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 2ce2f12..f742d33 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -98,7 +98,7 @@ export class GitHubService implements GitServiceInterface { async pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise { const url = this.getApiUrl(path); - const sha = existingSha !== undefined ? existingSha : (await this.getFile(path, branch)).sha; + const sha = existingSha === undefined ? (await this.getFile(path, branch)).sha : existingSha; const body: Record = { message: commitMessage, @@ -270,7 +270,7 @@ export class GitHubService implements GitServiceInterface { const bytes = new TextEncoder().encode(str); let binary = ''; bytes.forEach((byte) => { - binary += String.fromCharCode(byte); + binary += String.fromCodePoint(byte); }); return btoa(binary); } @@ -279,7 +279,7 @@ export class GitHubService implements GitServiceInterface { 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); + bytes[i] = binary.codePointAt(i) || 0; } return new TextDecoder().decode(bytes); } diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 1e785f6..905aef4 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -105,7 +105,7 @@ export class GitLabService implements GitServiceInterface { async pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise { const url = this.getApiUrl(path); - const sha = existingSha !== undefined ? existingSha : (await this.getFile(path, branch)).sha; + const sha = existingSha === undefined ? (await this.getFile(path, branch)).sha : existingSha; const method = sha ? 'PUT' : 'POST'; const response = await this.safeRequest({ @@ -154,7 +154,8 @@ export class GitLabService implements GitServiceInterface { async listFiles(branch: string, path: string = ''): Promise { 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 searchPathParam = searchPath ? `&path=${encodeURIComponent(searchPath)}` : ''; + const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100${searchPathParam}`; const response = await this.safeRequest({ url, @@ -244,7 +245,7 @@ export class GitLabService implements GitServiceInterface { const bytes = new TextEncoder().encode(str); let binary = ''; bytes.forEach((byte) => { - binary += String.fromCharCode(byte); + binary += String.fromCodePoint(byte); }); return btoa(binary); } @@ -253,7 +254,7 @@ export class GitLabService implements GitServiceInterface { 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); + bytes[i] = binary.codePointAt(i) || 0; } return new TextDecoder().decode(bytes); } diff --git a/src/settings.ts b/src/settings.ts index 2dea553..e826798 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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(); })); } } diff --git a/src/ui/ConfirmModal.ts b/src/ui/ConfirmModal.ts index 08eeaaf..58f6210 100644 --- a/src/ui/ConfirmModal.ts +++ b/src/ui/ConfirmModal.ts @@ -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); diff --git a/src/ui/SyncConflictModal.ts b/src/ui/SyncConflictModal.ts index 9178b2f..dd89656 100644 --- a/src/ui/SyncConflictModal.ts +++ b/src/ui/SyncConflictModal.ts @@ -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); diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index bcaed44..17df172 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -29,10 +29,10 @@ type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; export class SyncStatusView extends ItemView { plugin: GitLabFilesPush; - private fileStatuses: Map = new Map(); + private readonly fileStatuses: Map = new Map(); private isRefreshing = false; private statusFilter: FilterValue = 'all'; - private selectedFiles: Set = new Set(); + private readonly selectedFiles: Set = new Set(); private lastSyncTime: number = 0; constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush) { @@ -135,67 +135,77 @@ 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' }); + this.renderSelectAllRow(bar, allSelected, visible); + this.renderActionButtons(bar, canPush, canPull, canDelete); + } + } - 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 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)) + }; + } - 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()); + 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()); + } - 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()); + 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(); + }); + } - 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 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 { @@ -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 { + 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,90 +511,123 @@ export class SyncStatusView extends ItemView { this.isRefreshing = true; this.fileStatuses.clear(); + this.showProgressIndicator(); - // 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%'); - } + try { + 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(); + + const filesToCheck = this.getCheckableFiles(files.local, extra); + await this.performStatusCheck(filesToCheck); + + this.lastSyncTime = Date.now(); + this.renderView(); + 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 { + this.isRefreshing = false; } + } - try { - const allFiles = this.app.vault.getFiles(); - let files = this.plugin.filterFilesByVaultFolder(allFiles); + 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%'); + } - let remoteFiles = await this.plugin.gitService.listFiles(this.plugin.settings.branch); + 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(allFiles.map(f => [f.path, f])) + }; + } - await this.plugin.gitignoreManager.loadGitignores(); - remoteFiles = remoteFiles.filter(p => !this.plugin.gitignoreManager.isIgnored(p)); - files = files.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path)); + private initializeFileStatuses(localFiles: TFile[]): void { + for (const file of localFiles) { + this.fileStatuses.set(file.path, { file, path: file.path, status: 'checking' }); + } + } - const localFilePaths = new Set(files.map(f => f.path)); - const allLocalFileMap = new Map(allFiles.map(f => [f.path, f])); + private async identifyExtraFiles(remoteFiles: string[], localFilePaths: Set, allLocalFileMap: Map) { + const extra: Array = []; + for (const remotePath of remoteFiles) { + if (localFilePaths.has(remotePath)) continue; - for (const file of files) { - this.fileStatuses.set(file.path, { file, path: file.path, status: 'checking' }); + let localFile = allLocalFileMap.get(remotePath); + if (!localFile) { + const abs = this.app.vault.getAbstractFileByPath(remotePath); + if (abs instanceof TFile) localFile = abs; } - const extraFilesToCheck: Array = []; - 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' }); - } - } + 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; + } - this.renderView(); + private addExtraToStatuses(extra: Array): 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' }); + } + } - let filesToCheck: Array = [...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; + private getCheckableFiles(local: TFile[], extra: Array) { + const combined: Array = [...local, ...extra]; + return combined.filter(f => { + const p = typeof f === 'string' ? f : f.path; + return !this.plugin.gitignoreManager.isIgnored(p); + }); + } - for (const file of filesToCheck) { + private async performStatusCheck(filesToCheck: Array): Promise { + const total = filesToCheck.length; + for (let i = 0; i < total; i++) { + const file = filesToCheck[i]; + if (file) { 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}%)`; - } - } } + this.updateRefreshProgress(i + 1, total); + } + } - this.lastSyncTime = Date.now(); - this.renderView(); - new Notice(`Checked ${files.length} local + ${remoteFiles.length} remote files`); - } catch (e) { - new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`); - } finally { - this.isRefreshing = false; + 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}%)`; } } @@ -705,28 +748,51 @@ export class SyncStatusView extends ItemView { } async deleteSelected(): Promise { - if (this.selectedFiles.size === 0) { new Notice('No files selected'); return; } + const targets = this.getSelectedTargets(); + if (targets.length === 0) return; + + const { local, remote } = this.partitionTargets(targets); + if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; } + + if (!await this.confirmDeletion(local.length, remote.length)) return; - const targets = Array.from(this.selectedFiles) + const total = local.length + remote.length; + const prog = new Notice(`Deleting 0/${total} files…`, 0); + const errors: string[] = []; + + 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[]; + } - const local = targets.filter(s => s.status !== 'remote-only'); - const remote = targets.filter(s => s.status === 'remote-only'); - if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; } + 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 { 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 (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.`; - if (!await this.showConfirmDialog(msg)) return; + return await this.showConfirmDialog(msg); + } - const total = local.length + remote.length; - const prog = new Notice(`Deleting 0/${total} files…`, 0); - const errors: string[] = []; + private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: string[]): Promise { 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 { + 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 { diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 19c9044..91cd0d0 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -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(); }); }); }); From 08fd2b5d738de43e01c52d77051c9664c6906abe Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 25 Apr 2026 21:06:26 +0000 Subject: [PATCH 4/7] refactor: extract BaseGitService to reduce duplication and fix lint/tests --- src/main.ts | 8 +- src/services/git-service-base.ts | 96 +++++++++ src/services/git-service-interface.ts | 2 +- src/services/github-service.ts | 300 ++++++-------------------- src/services/gitlab-service.ts | 275 ++++++----------------- tests/services/github-service.test.ts | 8 +- tests/services/gitlab-service.test.ts | 67 +++--- 7 files changed, 267 insertions(+), 489 deletions(-) create mode 100644 src/services/git-service-base.ts diff --git a/src/main.ts b/src/main.ts index 5c79b80..6056fa6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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) { diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts new file mode 100644 index 0000000..3731af6 --- /dev/null +++ b/src/services/git-service-base.ts @@ -0,0 +1,96 @@ +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; +} + +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): Promise { + try { + const headers: Record = { + ...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): 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; + abstract pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise; + abstract listFiles(branch: string): Promise; + abstract deleteFile(path: string, branch: string, message: string): Promise; + abstract testConnection(): Promise; + abstract getRepoGitignores(branch: string): Promise; +} diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index 0cbe05b..c2e7f07 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -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; - testConnection(): Promise; + testConnection(): Promise; listFiles(branch: string, path?: string): Promise; deleteFile(path: string, branch: string, commitMessage: string): Promise; getRepoGitignores(branch: string): Promise; diff --git a/src/services/github-service.ts b/src/services/github-service.ts index f742d33..3791397 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -1,285 +1,111 @@ -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; - 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): 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 { + async getFile(path: string, branch: string): Promise { 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, commitMessage: string, existingSha?: string): Promise { + async pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise { const url = this.getApiUrl(path); - - const sha = existingSha === undefined ? (await this.getFile(path, branch)).sha : existingSha; - - const body: Record = { - message: commitMessage, - content: this.toBase64(content), - branch + const body = { + message, + content: this.encodeContent(content), + branch, + sha }; - 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; - } - - async testConnection(): Promise { - 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}`; - - 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; - } + const response = await this.safeRequest(url, 'PUT', body); + const data = response.json as { content: { path: string } }; + return data.content.path; } - async listFiles(branch: string, _path: string = ''): Promise { + async listFiles(branch: string): Promise { 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 + 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 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 { + async deleteFile(path: string, branch: string, message: string): Promise { + const file = await this.getFile(path, branch); const url = this.getApiUrl(path); + const body = { + message, + sha: file.sha, + branch + }; - // 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 - }) - }); + 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 { + try { + 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 { - 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.fromCodePoint(byte); - }); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCodePoint(bytes[i]); + } 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.codePointAt(i) || 0; + const cp = binary.codePointAt(i); + bytes[i] = cp !== undefined ? cp : 0; } return new TextDecoder().decode(bytes); } diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 905aef4..05236ac 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -1,260 +1,115 @@ - -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; - 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): 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 { + async getFile(path: string, branch: string): Promise { 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.blob_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, commitMessage: string, existingSha?: string): Promise { + async pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise { const url = this.getApiUrl(path); + const body = { + branch, + content: this.encodeContent(content), + encoding: 'base64', + commit_message: message, + last_commit_id: sha + }; - const sha = existingSha === undefined ? (await this.getFile(path, branch)).sha : existingSha; 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; - } - - async testConnection(): Promise { - if (!this.token) throw new Error('Token is missing'); - if (!this.projectId) throw new Error('Project ID is missing'); - - 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}`); - } + const response = await this.safeRequest(url, method, body); + const data = response.json as GitLabFileResponse; + return data.file_path; } - async listFiles(branch: string, path: string = ''): Promise { + async listFiles(branch: string): Promise { const encodedProjectId = encodeURIComponent(this.projectId); - const searchPath = this.rootPath || path || ''; - const searchPathParam = searchPath ? `&path=${encodeURIComponent(searchPath)}` : ''; - const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100${searchPathParam}`; - - 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 { + async deleteFile(path: string, branch: string, message: string): Promise { 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 { + 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 { - 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.fromCodePoint(byte); - }); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCodePoint(bytes[i]); + } 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.codePointAt(i) || 0; + const cp = binary.codePointAt(i); + bytes[i] = cp !== undefined ? cp : 0; } return new TextDecoder().decode(bytes); } diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index f256f03..c403630 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -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'); diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index ed19999..4e6c7c9 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -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)); - })), + content: btoa('hello world'), blob_id: 'test-sha' } - }; - 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 } - })); + + 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 () => { @@ -44,14 +46,14 @@ describe('GitLabService', () => { }); it('should return blob_id as sha', async () => { - const mockResponse: unknown = { + const mockResponse = { status: 200, json: { content: btoa('test content'), blob_id: 'test-blob-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'); }); @@ -59,36 +61,31 @@ describe('GitLabService', () => { 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')); }); }); }); From 6c0ca4ce698720eb76431d3602c7cb292e12893f Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 25 Apr 2026 21:10:03 +0000 Subject: [PATCH 5/7] fix: resolve TS build errors due to unchecked indexed access --- src/services/github-service.ts | 5 ++++- src/services/gitlab-service.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 3791397..ea606f0 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -95,7 +95,10 @@ export class GitHubService extends BaseGitService implements GitServiceInterface const bytes = new TextEncoder().encode(content); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCodePoint(bytes[i]); + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCodePoint(byte); + } } return btoa(binary); } diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 05236ac..c9110f3 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -99,7 +99,10 @@ export class GitLabService extends BaseGitService implements GitServiceInterface const bytes = new TextEncoder().encode(content); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCodePoint(bytes[i]); + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCodePoint(byte); + } } return btoa(binary); } From 26be2e820b5c1ff48fbba325ca8dada5f69c170e Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 25 Apr 2026 21:20:13 +0000 Subject: [PATCH 6/7] 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 --- src/logic/sync-manager.ts | 10 ++++++++++ src/services/git-service-base.ts | 1 + src/services/gitlab-service.ts | 2 +- tests/services/gitlab-service.test.ts | 10 +++++----- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index d62a543..a913f21 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -302,6 +302,16 @@ export class SyncManager { 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); diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 3731af6..72133e7 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -24,6 +24,7 @@ export interface GitLabFileResponse { content: string; blob_id: string; file_path: string; + last_commit_id: string; } export interface GitLabTreeItem { diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index c9110f3..9ac1dfd 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -31,7 +31,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface return { content: this.decodeContent(data.content), - sha: data.blob_id + sha: data.last_commit_id }; } catch (e) { if (e instanceof Error && e.message.includes('404')) { diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index 4e6c7c9..f1901da 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -20,7 +20,7 @@ describe('GitLabService', () => { status: 200, json: { content: btoa('hello world'), - blob_id: 'test-sha' + last_commit_id: 'test-commit-id' } } as unknown as RequestUrlResponse; vi.mocked(requestUrl).mockResolvedValue(mockResponse); @@ -28,7 +28,7 @@ describe('GitLabService', () => { const result = await service.getFile('test.md', 'main'); expect(result.content).toBe('hello world'); - expect(result.sha).toBe('test-sha'); + expect(result.sha).toBe('test-commit-id'); const calls = vi.mocked(requestUrl).mock.calls; const lastCallParams = calls[0]; @@ -45,17 +45,17 @@ describe('GitLabService', () => { expect(result.sha).toBe(''); }); - it('should return blob_id as sha', async () => { + 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' } } 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'); }); }); From d44495e4d76d99ac489c995ee00e8923a831242d Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 25 Apr 2026 21:25:17 +0000 Subject: [PATCH 7/7] refactor: eliminate duplication by moving encoding logic to BaseGitService and fix getFullPath regression --- src/services/git-service-base.ts | 35 +++++++++++++++++++++++++++++--- src/services/github-service.ts | 21 ------------------- src/services/gitlab-service.ts | 21 ------------------- 3 files changed, 32 insertions(+), 45 deletions(-) diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 72133e7..1de81f1 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -82,10 +82,17 @@ export abstract class BaseGitService { } protected getFullPath(path: string): string { + // If path starts with /, it's an absolute path from repo root, bypass rootPath + if (path.startsWith('/')) { + return path.slice(1); + } + 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; + + const cleanRoot = this.rootPath.replace(/\/+$/, ''); // Remove trailing slashes + const cleanPath = path.replace(/^\/+/, ''); // Remove leading slashes + + return cleanRoot ? `${cleanRoot}/${cleanPath}` : cleanPath; } abstract getFile(path: string, branch: string): Promise; @@ -94,4 +101,26 @@ export abstract class BaseGitService { abstract deleteFile(path: string, branch: string, message: string): Promise; abstract testConnection(): Promise; abstract getRepoGitignores(branch: string): Promise; + + protected encodeContent(content: string): string { + const bytes = new TextEncoder().encode(content); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte !== undefined) { + binary += String.fromCodePoint(byte); + } + } + return btoa(binary); + } + + protected 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++) { + const cp = binary.codePointAt(i); + bytes[i] = cp !== undefined ? cp : 0; + } + return new TextDecoder().decode(bytes); + } } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index ea606f0..536be28 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -91,25 +91,4 @@ export class GitHubService extends BaseGitService implements GitServiceInterface return allFiles.filter(p => p.endsWith('.gitignore')); } - private encodeContent(content: string): string { - const bytes = new TextEncoder().encode(content); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - const byte = bytes[i]; - if (byte !== undefined) { - binary += String.fromCodePoint(byte); - } - } - return btoa(binary); - } - - 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++) { - const cp = binary.codePointAt(i); - bytes[i] = cp !== undefined ? cp : 0; - } - return new TextDecoder().decode(bytes); - } } diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 9ac1dfd..8290a67 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -95,25 +95,4 @@ export class GitLabService extends BaseGitService implements GitServiceInterface return allFiles.filter(p => p.endsWith('.gitignore')); } - private encodeContent(content: string): string { - const bytes = new TextEncoder().encode(content); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - const byte = bytes[i]; - if (byte !== undefined) { - binary += String.fromCodePoint(byte); - } - } - return btoa(binary); - } - - 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++) { - const cp = binary.codePointAt(i); - bytes[i] = cp !== undefined ? cp : 0; - } - return new TextDecoder().decode(bytes); - } }