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:
ClaudiaFang 2026-04-27 02:16:26 +08:00 committed by GitHub
parent 4574abc294
commit 70171fd5d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 395 additions and 144 deletions

View file

@ -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 }}

View file

@ -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
View file

@ -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": {

View file

@ -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
View 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)" }
]
}
}
]
}

View file

@ -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;

View file

@ -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;

View file

@ -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> {

View file

@ -18,6 +18,7 @@ export interface GitHubTreeItem {
export interface GitHubTreeResponse {
tree: GitHubTreeItem[];
truncated: boolean;
}
export interface GitLabFileResponse {

View file

@ -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)

View file

@ -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> {

View file

@ -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}`);

View file

@ -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.`;