mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
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 <tianyao.firstsun@gmail.coim>
This commit is contained in:
parent
4574abc294
commit
70171fd5d7
13 changed files with 395 additions and 144 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
178
package-lock.json
generated
178
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
22
ruleset_update.json
Normal file
22
ruleset_update.json
Normal file
|
|
@ -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)" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
|
||||
constructor(app: App, gitService: GitServiceInterface, settings: GitLabFilesPushSettings) {
|
||||
constructor(app: App, gitService: GitServiceInterface, settings: GitLabFilesPushSettings, onSaveSettings?: () => Promise<void>) {
|
||||
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<void> {
|
||||
|
|
@ -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<string, { saveSettings: () => Promise<void> }> } }).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;
|
||||
|
|
|
|||
15
src/main.ts
15
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<void> {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface GitHubTreeItem {
|
|||
|
||||
export interface GitHubTreeResponse {
|
||||
tree: GitHubTreeItem[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface GitLabFileResponse {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -56,14 +56,29 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
async listFiles(branch: string): Promise<string[]> {
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100`;
|
||||
const response = await this.safeRequest(url, '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<void> {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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<string, FileStatus> = 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.`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue