From 70171fd5d7cbae9e0943c21b6317e89fdd72c8bc Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Mon, 27 Apr 2026 02:16:26 +0800 Subject: [PATCH] refactor: fix quality gate issues, improve type safety and pagination (#21) * refactor: fix quality gate issues, improve type safety and pagination * refactor: integrate eslint-plugin-sonarjs and fix cognitive complexity issues * fix(ci): remove invalid secrets reference in workflow with block * fix(build): resolve strict null check errors in diff logic --------- Co-authored-by: ClaudiaFang --- .github/workflows/ci.yml | 2 +- eslint.config.mts | 5 + package-lock.json | 178 +++++++++++++++++++++++++++- package.json | 2 +- ruleset_update.json | 22 ++++ src/logic/gitignore-manager.ts | 25 ++-- src/logic/sync-manager.ts | 54 +++++---- src/main.ts | 15 +-- src/services/git-service-base.ts | 1 + src/services/github-service.ts | 4 + src/services/gitlab-service.ts | 29 +++-- src/settings.ts | 8 +- src/ui/SyncStatusView.ts | 194 +++++++++++++++++++------------ 13 files changed, 395 insertions(+), 144 deletions(-) create mode 100644 ruleset_update.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae52bf1..ffea931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: uses: firstsun-dev/general-workflows/.github/workflows/obsidian-plugin-ci.yml@main with: plugin-id: "git-file-sync" - skip-sonar: ${{ secrets.SONAR_TOKEN == '' || vars.ENABLE_CI_SONAR != 'true' }} + skip-sonar: ${{ vars.ENABLE_CI_SONAR != 'true' }} secrets: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/eslint.config.mts b/eslint.config.mts index 324669f..9e19aaa 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -1,5 +1,6 @@ import tseslint from 'typescript-eslint'; import obsidianmd from "eslint-plugin-obsidianmd"; +import sonarjs from "eslint-plugin-sonarjs"; import globals from "globals"; import { globalIgnores } from "eslint/config"; @@ -22,6 +23,10 @@ export default tseslint.config( }, }, ...obsidianmd.configs.recommended, + { + files: ["src/**/*.ts", "src/**/*.tsx"], + ...sonarjs.configs.recommended, + }, globalIgnores([ "node_modules", "dist", diff --git a/package-lock.json b/package-lock.json index 5a262b7..de41265 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "conventional-changelog-conventionalcommits": "^9.3.1", "esbuild": "0.28.0", "eslint-plugin-obsidianmd": "0.1.9", + "eslint-plugin-sonarjs": "^4.0.3", "globals": "14.0.0", "husky": "^9.1.7", "jiti": "2.6.1", @@ -3510,6 +3511,29 @@ "node": ">=8" } }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5268,6 +5292,95 @@ "safe-regex": "^2.1.1" } }, + "node_modules/eslint-plugin-sonarjs": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.3.tgz", + "integrity": "sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.4.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.5", + "scslre": "^0.3.0", + "semver": "^7.7.4", + "ts-api-utils": "^2.5.0", + "typescript": ">=5" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -5757,6 +5870,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -7135,6 +7255,16 @@ "node": ">=4.0" } }, + "node_modules/jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10675,6 +10805,19 @@ "dev": true, "license": "MIT" }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10698,6 +10841,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -10980,6 +11137,21 @@ "node": ">=v12.22.7" } }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, "node_modules/semantic-release": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", @@ -12256,9 +12428,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index a99f052..8592840 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "conventional-changelog-conventionalcommits": "^9.3.1", "esbuild": "0.28.0", "eslint-plugin-obsidianmd": "0.1.9", + "eslint-plugin-sonarjs": "^4.0.3", "globals": "14.0.0", "husky": "^9.1.7", "jiti": "2.6.1", @@ -46,4 +47,3 @@ "obsidian": "latest" } } - diff --git a/ruleset_update.json b/ruleset_update.json new file mode 100644 index 0000000..9f00a31 --- /dev/null +++ b/ruleset_update.json @@ -0,0 +1,22 @@ +{ + "name": "master", + "enforcement": "active", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "CI / Lint" }, + { "context": "CI / Test (Node 22)" } + ] + } + } + ] +} diff --git a/src/logic/gitignore-manager.ts b/src/logic/gitignore-manager.ts index a7e7cfa..5e30996 100644 --- a/src/logic/gitignore-manager.ts +++ b/src/logic/gitignore-manager.ts @@ -87,27 +87,18 @@ export class GitignoreManager { * Checks if a given file path should be ignored based on loaded .gitignore rules. */ isIgnored(filePath: string): boolean { - // Path relative to Git Root const fullPath = this.rootPath ? `${this.rootPath}/${filePath}` : filePath; - // Iterate through all loaded .gitignore files. for (const [dirPath, ig] of this.ignoreMap.entries()) { if (dirPath === '') { - // Root .gitignore applies to everything (test against fullPath) - if (ig.ignores(fullPath)) { - return true; - } - } else { - // Subdirectory .gitignore - // Check if the file is inside this directory (dirPath is relative to git root) - const prefix = dirPath + '/'; - if (fullPath.startsWith(prefix)) { - // Extract the path relative to the directory containing .gitignore - const relativePath = fullPath.substring(prefix.length); - if (ig.ignores(relativePath)) { - return true; - } - } + if (ig.ignores(fullPath)) return true; + continue; + } + + const prefix = dirPath + '/'; + if (fullPath.startsWith(prefix)) { + const relativePath = fullPath.substring(prefix.length); + if (ig.ignores(relativePath)) return true; } } return false; diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index fc383ef..2264d2b 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -1,21 +1,23 @@ import { TFile, App, Notice } from 'obsidian'; import { GitServiceInterface } from '../services/git-service-interface'; -import { GitLabFilesPushSettings } from '../settings'; +import { GitLabFilesPushSettings, getServiceName } from '../settings'; import { SyncConflictModal } from '../ui/SyncConflictModal'; export class SyncManager { private readonly app: App; private gitService: GitServiceInterface; private readonly settings: GitLabFilesPushSettings; + private readonly onSaveSettings?: () => Promise; - constructor(app: App, gitService: GitServiceInterface, settings: GitLabFilesPushSettings) { + constructor(app: App, gitService: GitServiceInterface, settings: GitLabFilesPushSettings, onSaveSettings?: () => Promise) { this.app = app; this.gitService = gitService; this.settings = settings; + this.onSaveSettings = onSaveSettings; } private get serviceName(): string { - return this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + return getServiceName(this.settings); } public async updateMetadata(path: string, sha: string): Promise { @@ -66,8 +68,7 @@ export class SyncManager { await this.performPull(fileRep, remote.content, remote.sha); } } catch (e) { - console.error(e); - new Notice(`Failed to resolve conflict for ${name}: ${e instanceof Error ? e.message : String(e)}`); + this.handleError(`Failed to resolve conflict for ${name}`, e); } })(); }).open(); @@ -76,8 +77,7 @@ export class SyncManager { await this.performPush({ path, name }, content, remote.sha); } catch (e) { - console.error(e); - new Notice(`Failed to push ${name} to ${this.serviceName}: ${e instanceof Error ? e.message : String(e)}`); + this.handleError(`Failed to push ${name} to ${this.serviceName}`, e); } } @@ -124,8 +124,7 @@ export class SyncManager { await this.saveSettings(); new Notice(`Renamed and pushed ${file.name} to ${this.serviceName}\nNote: Old file at ${oldPath} may need manual deletion from remote`); } catch (e) { - console.error(e); - new Notice(`Failed to handle rename: ${e instanceof Error ? e.message : String(e)}`); + this.handleError('Failed to handle rename', e); } } @@ -178,8 +177,7 @@ export class SyncManager { await this.performPull(fileRep, remote.content, remote.sha); } } catch (e) { - console.error(e); - new Notice(`Failed to resolve conflict for ${name}: ${e instanceof Error ? e.message : String(e)}`); + this.handleError(`Failed to resolve conflict for ${name}`, e); } })(); }).open(); @@ -189,8 +187,7 @@ export class SyncManager { 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 ${this.serviceName}: ${e instanceof Error ? e.message : String(e)}`); + this.handleError(`Failed to pull ${name} from ${this.serviceName}`, e); } } @@ -225,13 +222,17 @@ export class SyncManager { } private async saveSettings() { - const plugins = (this.app as unknown as { plugins: { plugins: Record Promise }> } }).plugins; - const plugin = plugins?.plugins?.['git-file-sync']; - if (plugin && typeof plugin.saveSettings === 'function') { - await plugin.saveSettings(); + if (this.onSaveSettings) { + await this.onSaveSettings(); } } + private handleError(message: string, error: unknown): void { + console.error(message, error); + const detail = error instanceof Error ? error.message : String(error); + new Notice(`${message}: ${detail}`); + } + async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { return this.processBatch(files, 'push', onProgress); } @@ -246,17 +247,13 @@ export class SyncManager { onProgress?: (current: number, total: number, fileName: string) => void ): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> }; - const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; for (let i = 0; i < files.length; i++) { const fileOrPath = files[i]; if (!fileOrPath) continue; const { path, name, isString } = this.getFileInfo(fileOrPath); - - if (onProgress) { - onProgress(i + 1, files.length, name); - } + onProgress?.(i + 1, files.length, name); try { if (op === 'push') { @@ -273,12 +270,21 @@ export class SyncManager { } await this.saveSettings(); - if (results.success > 0) new Notice(`${op === 'push' ? 'Pushed' : 'Pulled'} ${results.success} file(s) to ${serviceName}`); - if (results.failed > 0) new Notice(`Failed to ${op} ${results.failed} file(s). Check console for details.`); + this.notifyBatchResult(op, results.success, results.failed); return results; } + private notifyBatchResult(op: 'push' | 'pull', success: number, failed: number): void { + const opName = op === 'push' ? 'Pushed' : 'Pulled'; + if (success > 0) { + new Notice(`${opName} ${success} file(s) to ${this.serviceName}`); + } + if (failed > 0) { + new Notice(`Failed to ${op} ${failed} file(s). Check console for details.`); + } + } + private getFileInfo(fileOrPath: TFile | string) { const isString = typeof fileOrPath === 'string'; const path = isString ? fileOrPath : fileOrPath.path; diff --git a/src/main.ts b/src/main.ts index 5a207f1..261f4c5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { Plugin, TFile, MarkdownView, Notice, Platform } from 'obsidian'; -import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab } from "./settings"; +import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab, getServiceName } from "./settings"; import { GitLabService } from './services/gitlab-service'; import { GitHubService } from './services/github-service'; import { GitServiceInterface } from './services/git-service-interface'; @@ -37,7 +37,7 @@ export default class GitLabFilesPush extends Plugin { this.initializeGitService(); this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath); - this.sync = new SyncManager(this.app, this.gitService, this.settings); + this.sync = new SyncManager(this.app, this.gitService, this.settings, this.saveSettings.bind(this)); this.addRibbonIcon('upload-cloud', Platform.isMobile ? `Push` : `Push to ${this.serviceName}`, async () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); @@ -102,19 +102,10 @@ export default class GitLabFilesPush extends Plugin { } }) ); - - this.registerEvent( - this.app.workspace.on('file-open', (file) => { - if (file instanceof TFile && (this.settings.gitlabToken || this.settings.githubToken)) { - // Optional: Check for updates automatically - // this.sync.pullFile(file); - } - }) - ); } private get serviceName(): string { - return this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + return getServiceName(this.settings); } async activateSyncStatusView(): Promise { diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index fbc0546..453605f 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -18,6 +18,7 @@ export interface GitHubTreeItem { export interface GitHubTreeResponse { tree: GitHubTreeItem[]; + truncated: boolean; } export interface GitLabFileResponse { diff --git a/src/services/github-service.ts b/src/services/github-service.ts index c1d613c..9d8070c 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -55,6 +55,10 @@ export class GitHubService extends BaseGitService implements GitServiceInterface const response = await this.safeRequest(url, 'GET'); const data = response.json as GitHubTreeResponse; + if (data.truncated) { + console.warn('GitHub tree result is truncated. Some files might not be shown.'); + } + return data.tree .filter(item => item.type === 'blob') .map(item => item.path) diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 680feb5..d896ba3 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -56,14 +56,29 @@ export class GitLabService extends BaseGitService implements GitServiceInterface async listFiles(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, 'GET'); - const data = response.json as GitLabTreeItem[]; + let allPaths: string[] = []; + let page = 1; + const perPage = 100; - return data - .filter(item => item.type === 'blob') - .map(item => item.path) - .filter(p => !this.rootPath || p.startsWith(this.rootPath)); + while (true) { + const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`; + const response = await this.safeRequest(url, 'GET'); + const data = response.json as GitLabTreeItem[]; + + if (!data || data.length === 0) break; + + const paths = data + .filter(item => item.type === 'blob') + .map(item => item.path) + .filter(p => !this.rootPath || p.startsWith(this.rootPath)); + + allPaths = allPaths.concat(paths); + + if (data.length < perPage) break; + page++; + } + + return allPaths; } async deleteFile(path: string, branch: string, message: string): Promise { diff --git a/src/settings.ts b/src/settings.ts index e826798..7ca0cfb 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -23,6 +23,10 @@ export interface GitLabFilesPushSettings { vaultFolder: string; } +export function getServiceName(settings: GitLabFilesPushSettings): string { + return settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; +} + export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { serviceType: 'gitlab', gitlabToken: '', @@ -108,13 +112,13 @@ export class GitLabSyncSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Test connection') - .setDesc(`Verify your ${this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'} settings`) + .setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`) .addButton(button => button .setButtonText('Test connection') .onClick(async () => { try { await this.plugin.gitService.testConnection(); - new Notice(`${this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'} connection successful!`); + new Notice(`${getServiceName(this.plugin.settings)} connection successful!`); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); new Notice(`Connection failed: ${message}`); diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 843c969..8ef30f5 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,5 +1,6 @@ import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'obsidian'; import GitLabFilesPush from '../main'; +import { getServiceName } from '../settings'; import { ConfirmModal } from './ConfirmModal'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; @@ -27,6 +28,14 @@ interface DiffRow { type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; +type DiffOpType = 'unchanged' | 'removed' | 'added'; + +interface DiffOp { + type: DiffOpType; + li: number; + ri: number; +} + export class SyncStatusView extends ItemView { plugin: GitLabFilesPush; private readonly fileStatuses: Map = new Map(); @@ -72,7 +81,7 @@ export class SyncStatusView extends ItemView { private renderInfoStrip(container: HTMLElement): void { const el = container.createDiv({ cls: 'ssv-info' }); - const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + const serviceName = getServiceName(this.plugin.settings); el.createSpan({ cls: 'ssv-info-item', text: serviceName }); @@ -186,26 +195,18 @@ export class SyncStatusView extends ItemView { } 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()); + this.renderLargeButton(bar, '↑', ` Push (${canPush})`, `Push ${canPush} files`, () => void this.pushSelected(), 'push', canPush === 0); + this.renderLargeButton(bar, '↓', ` Pull (${canPull})`, `Pull ${canPull} files`, () => void this.pullSelected(), 'pull', canPull === 0); + this.renderLargeButton(bar, '✕', ` Delete (${canDelete})`, `Delete ${canDelete} files`, () => void this.deleteSelected(), 'danger', canDelete === 0); + } - 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 renderLargeButton(container: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string, disabled: boolean): void { + const btn = container.createEl('button', { cls: `ssv-btn ssv-btn-${cls}` }); + btn.createSpan({ text: icon }); + btn.createSpan({ cls: 'ssv-btn-label', text: label }); + btn.disabled = disabled; + setTooltip(btn, tooltip); + btn.addEventListener('click', onClick); } private renderFileList(container: HTMLElement): void { @@ -386,89 +387,128 @@ export class SyncStatusView extends ItemView { } private computeSideBySideDiff(remote: string, local: string): DiffRow[] { - const normalize = (s: string) => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - const L = normalize(remote).split('\n'); - const R = normalize(local).split('\n'); + const L = this.normalizeContent(remote).split('\n'); + const R = this.normalizeContent(local).split('\n'); const m = L.length, n = R.length; - // For very large files fall back to simple comparison - if (m * n > 250_000) return this.simpleDiff(L, R); + if (m * n > 250_000 || (m + 1) * (n + 1) > 1_000_000) { + return this.simpleDiff(L, R); + } - // LCS DP — flat Uint32Array avoids noUncheckedIndexedAccess issues + const dp = this.buildDPMatrix(L, R, m, n); + const ops = this.tracePath(L, R, dp, m, n); + return this.pairDiffOps(ops, L, R); + } + + private normalizeContent(s: string): string { + return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + } + + private buildDPMatrix(L: string[], R: string[], m: number, n: number): Uint32Array { const W = n + 1; - const totalSize = (m + 1) * W; - - // Security hotspot: prevent huge memory allocations that could lead to DoS - if (totalSize > 1_000_000) return this.simpleDiff(L, R); - - const dp = new Uint32Array(totalSize); + const dp = new Uint32Array((m + 1) * W); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i * W + j] = L[i - 1] === R[j - 1] - ? (dp[(i - 1) * W + (j - 1)] ?? 0) + 1 - : Math.max(dp[(i - 1) * W + j] ?? 0, dp[i * W + (j - 1)] ?? 0); + ? (dp[(i - 1) * W + (j - 1)]!) + 1 + : Math.max(dp[(i - 1) * W + j]!, dp[i * W + (j - 1)]!); } } + return dp; + } - // Backtrack — li/ri = -1 when not applicable (avoids optional fields) - type Op = { type: 'unchanged' | 'removed' | 'added'; li: number; ri: number }; - const ops: Op[] = []; + private tracePath(L: string[], R: string[], dp: Uint32Array, m: number, n: number): DiffOp[] { + const W = n + 1; + const ops: DiffOp[] = []; let i = m, j = n; while (i > 0 || j > 0) { - if (i > 0 && j > 0 && L[i - 1] === R[j - 1]) { - ops.push({ type: 'unchanged', li: i - 1, ri: j - 1 }); - i--; j--; - } else if (j > 0 && (i === 0 || (dp[i * W + (j - 1)] ?? 0) >= (dp[(i - 1) * W + j] ?? 0))) { - ops.push({ type: 'added', li: -1, ri: j - 1 }); - j--; - } else { - ops.push({ type: 'removed', li: i - 1, ri: -1 }); - i--; - } + const op = this.getNextDiffOp(L, R, dp, W, i, j); + ops.push(op); + [i, j] = this.updateIndices(op, i, j); } - ops.reverse(); + return ops.reverse(); + } - // Pair consecutive removed ↔ added side-by-side + private updateIndices(op: DiffOp, i: number, j: number): [number, number] { + if (op.type === 'unchanged') return [i - 1, j - 1]; + if (op.type === 'added') return [i, j - 1]; + return [i - 1, j]; + } + + private getNextDiffOp(L: string[], R: string[], dp: Uint32Array, W: number, i: number, j: number): DiffOp { + if (i > 0 && j > 0 && L[i - 1] === R[j - 1]) { + return { type: 'unchanged', li: i - 1, ri: j - 1 }; + } + + const canAdd = j > 0; + const preferAdd = canAdd && (i === 0 || dp[i * W + (j - 1)]! >= dp[(i - 1) * W + j]!); + + if (preferAdd) { + return { type: 'added', li: -1, ri: j - 1 }; + } + return { type: 'removed', li: i - 1, ri: -1 }; + } + + private pairDiffOps(ops: DiffOp[], L: string[], R: string[]): DiffRow[] { const rows: DiffRow[] = []; let k = 0; while (k < ops.length) { const op = ops[k]; - if (op === undefined) break; + if (!op) break; if (op.type === 'unchanged') { - rows.push({ - left: { lineNum: op.li + 1, content: L[op.li] ?? null, type: 'unchanged' }, - right: { lineNum: op.ri + 1, content: R[op.ri] ?? null, type: 'unchanged' }, - }); + rows.push(this.createUnchangedRow(op, L, R)); k++; } else { - const removedIdxs: number[] = []; - const addedIdxs: number[] = []; - while (k < ops.length) { - const cur = ops[k]; - if (cur === undefined || cur.type === 'unchanged') break; - if (cur.type === 'removed') removedIdxs.push(cur.li); - else addedIdxs.push(cur.ri); - k++; - } - const len = Math.max(removedIdxs.length, addedIdxs.length); - for (let x = 0; x < len; x++) { - const rIdx = removedIdxs[x]; - const aIdx = addedIdxs[x]; - rows.push({ - 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' }, - }); - } + const batch = this.collectChangeBatch(ops, k); + rows.push(...this.createChangeRows(batch, L, R)); + k += batch.length; } } return rows; } + private createUnchangedRow(op: DiffOp, L: string[], R: string[]): DiffRow { + return { + left: { lineNum: op.li + 1, content: L[op.li] ?? null, type: 'unchanged' }, + right: { lineNum: op.ri + 1, content: R[op.ri] ?? null, type: 'unchanged' }, + }; + } + + private collectChangeBatch(ops: DiffOp[], startIdx: number): DiffOp[] { + const batch: DiffOp[] = []; + let k = startIdx; + while (k < ops.length) { + const item = ops[k]; + if (!item || item.type === 'unchanged') break; + batch.push(item); + k++; + } + return batch; + } + + private createChangeRows(batch: DiffOp[], L: string[], R: string[]): DiffRow[] { + const removedIdxs = batch.filter(o => o.type === 'removed').map(o => o.li); + const addedIdxs = batch.filter(o => o.type === 'added').map(o => o.ri); + const len = Math.max(removedIdxs.length, addedIdxs.length); + const rows: DiffRow[] = []; + + for (let x = 0; x < len; x++) { + rows.push({ + left: this.createDiffSide(removedIdxs[x], L, 'removed'), + right: this.createDiffSide(addedIdxs[x], R, 'added') + }); + } + return rows; + } + + private createDiffSide(idx: number | undefined, lines: string[], type: 'removed' | 'added'): DiffSide { + if (idx === undefined) { + return { lineNum: null, content: null, type: 'empty' }; + } + return { lineNum: idx + 1, content: lines[idx] ?? null, type }; + } + // Fallback for very large files (> 500×500 lines) private simpleDiff(L: string[], R: string[]): DiffRow[] { const rows: DiffRow[] = []; @@ -685,7 +725,7 @@ export class SyncStatusView extends ItemView { } const files = targets.map(s => s.file || s.path); - const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + const serviceName = getServiceName(this.plugin.settings); const msg = op === 'push' ? `Push ${files.length} file(s) to ${serviceName}?` : `Pull ${files.length} file(s) from ${serviceName}? This will overwrite local changes.`;