From cbc55d502dad099e7c93d170ac533d1550f86c1c Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Fri, 24 Apr 2026 12:41:36 +0000 Subject: [PATCH] feat: Support .gitignore filtering and GitHub-style diff viewer --- package-lock.json | 48 +++++++++++----- package.json | 1 + src/logic/gitignore-manager.ts | 100 +++++++++++++++++++++++++++++++++ src/logic/sync-manager.ts | 4 +- src/main.ts | 15 ++++- src/ui/SyncConflictModal.ts | 24 ++++---- src/ui/SyncStatusView.ts | 36 +++++++++--- styles.css | 29 ++++++++++ 8 files changed, 221 insertions(+), 36 deletions(-) create mode 100644 src/logic/gitignore-manager.ts diff --git a/package-lock.json b/package-lock.json index f320cad..302acae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "0-BSD", "dependencies": { + "ignore": "^7.0.5", "obsidian": "latest" }, "devDependencies": { @@ -960,6 +961,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@eslint/js": { "version": "9.30.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", @@ -2716,16 +2727,6 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/@typescript-eslint/parser": { "version": "8.35.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", @@ -5113,6 +5114,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint-plugin-n/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/eslint-plugin-n/node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -5318,6 +5329,16 @@ "url": "https://eslint.org/donate" } }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -6190,10 +6211,9 @@ } }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { "node": ">= 4" diff --git a/package.json b/package.json index 6d04747..48802db 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "vitest": "^4.1.2" }, "dependencies": { + "ignore": "^7.0.5", "obsidian": "latest" } } diff --git a/src/logic/gitignore-manager.ts b/src/logic/gitignore-manager.ts new file mode 100644 index 0000000..4444084 --- /dev/null +++ b/src/logic/gitignore-manager.ts @@ -0,0 +1,100 @@ +import ignore, { Ignore } from 'ignore'; +import { App } from 'obsidian'; +import { GitServiceInterface } from '../services/git-service-interface'; + +export class GitignoreManager { + private app: App; + private gitService: GitServiceInterface; + private branch: string; + + // Maps directory path (empty string for root) to Ignore instance + private ignoreMap: Map = new Map(); + + constructor(app: App, gitService: GitServiceInterface, branch: string) { + this.app = app; + this.gitService = gitService; + this.branch = branch; + } + + /** + * Discovers and parses .gitignore files from the local filesystem and remote repository. + * Uses remoteFiles as a hint for where .gitignore files exist in subdirectories. + */ + async loadGitignores(remoteFiles: string[]): Promise { + this.ignoreMap.clear(); + + // 1. Identify all unique directories that contain a .gitignore + const gitignorePaths = new Set(); + + // Root gitignore + gitignorePaths.add('.gitignore'); + + // Check remote files for any .gitignore in subdirectories + for (const remotePath of remoteFiles) { + if (remotePath.endsWith('.gitignore')) { + gitignorePaths.add(remotePath); + } + } + + // 2. Fetch and parse each .gitignore + for (const gitignorePath of gitignorePaths) { + const dirPath = gitignorePath === '.gitignore' ? '' : gitignorePath.slice(0, -('.gitignore'.length + 1)); + + let content: string | undefined; + + // Try local first + if (await this.app.vault.adapter.exists(gitignorePath)) { + try { + content = await this.app.vault.adapter.read(gitignorePath); + } catch (e) { + console.warn(`Failed to read local ${gitignorePath}`, e); + } + } + + // Fallback to remote + if (content === undefined && remoteFiles.includes(gitignorePath)) { + try { + const remoteFile = await this.gitService.getFile(gitignorePath, this.branch); + if (remoteFile && remoteFile.content) { + content = remoteFile.content; + } + } catch (e) { + console.warn(`Failed to fetch remote ${gitignorePath}`, e); + } + } + + if (content) { + const ig = ignore().add(content); + this.ignoreMap.set(dirPath, ig); + } + } + } + + /** + * Checks if a given file path should be ignored based on loaded .gitignore rules. + */ + isIgnored(filePath: string): boolean { + // Iterate through all loaded .gitignore files. + // A .gitignore file only applies to paths within its directory. + for (const [dirPath, ig] of this.ignoreMap.entries()) { + if (dirPath === '') { + // Root .gitignore applies to everything + if (ig.ignores(filePath)) { + return true; + } + } else { + // Subdirectory .gitignore + // Check if the file is inside this directory + const prefix = dirPath + '/'; + if (filePath.startsWith(prefix)) { + // Extract the path relative to the directory containing .gitignore + const relativePath = filePath.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 525da2b..5172f5f 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -213,8 +213,8 @@ export class SyncManager { private async performPull(file: TFile | {path: string, name: string}, remoteContent: string, remoteSha: string) { const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - if (file instanceof TFile || ('vault' in file)) { - await this.app.vault.modify(file as TFile, remoteContent); + if (file instanceof TFile) { + await this.app.vault.modify(file, remoteContent); } else { await this.app.vault.adapter.write(file.path, remoteContent); } diff --git a/src/main.ts b/src/main.ts index 4cf4076..bf67f46 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,11 +5,13 @@ import { GitHubService } from './services/github-service'; import { GitServiceInterface } from './services/git-service-interface'; import { SyncManager } from './logic/sync-manager'; import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView'; +import { GitignoreManager } from './logic/gitignore-manager'; export default class GitLabFilesPush extends Plugin { settings: GitLabFilesPushSettings; gitService: GitServiceInterface; sync: SyncManager; + gitignoreManager: GitignoreManager; async onload() { await this.loadSettings(); @@ -33,6 +35,7 @@ export default class GitLabFilesPush extends Plugin { }); this.initializeGitService(); + this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch); this.sync = new SyncManager(this.app, this.gitService, this.settings); const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; @@ -134,9 +137,13 @@ export default class GitLabFilesPush extends Plugin { async pushAllFiles(): Promise { const allFiles = this.app.vault.getFiles(); - const files = this.filterFilesByVaultFolder(allFiles); + let files = this.filterFilesByVaultFolder(allFiles); const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + const remoteFiles = await this.gitService.listFiles(this.settings.branch); + await this.gitignoreManager.loadGitignores(remoteFiles); + files = files.filter(f => !this.gitignoreManager.isIgnored(f.path)); + if (files.length === 0) { new Notice('No files to push in the configured vault folder'); return; @@ -166,9 +173,13 @@ export default class GitLabFilesPush extends Plugin { async pullAllFiles(): Promise { const allFiles = this.app.vault.getFiles(); - const files = this.filterFilesByVaultFolder(allFiles); + let files = this.filterFilesByVaultFolder(allFiles); const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + const remoteFiles = await this.gitService.listFiles(this.settings.branch); + await this.gitignoreManager.loadGitignores(remoteFiles); + files = files.filter(f => !this.gitignoreManager.isIgnored(f.path)); + if (files.length === 0) { new Notice('No files to pull in the configured vault folder'); return; diff --git a/src/ui/SyncConflictModal.ts b/src/ui/SyncConflictModal.ts index c717d6b..49db753 100644 --- a/src/ui/SyncConflictModal.ts +++ b/src/ui/SyncConflictModal.ts @@ -39,7 +39,7 @@ export class SyncConflictModal extends Modal { const diffSection = contentEl.createDiv({ cls: 'conflict-diff-section' }); diffSection.createEl('h3', { text: 'Differences' }); const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' }); - diffPre.createEl('code', { text: this.generateDiff() }); + this.renderDiff(diffPre); const buttonContainer = contentEl.createDiv({ cls: 'conflict-buttons' }); @@ -67,14 +67,18 @@ export class SyncConflictModal extends Modal { })); } - private generateDiff(): string { + private renderDiff(container: HTMLElement) { const localLines = this.localContent.split('\n'); const remoteLines = this.remoteContent.split('\n'); - const diff: string[] = []; - diff.push('--- Remote'); - diff.push('+++ Local'); - diff.push(''); + const createLine = (text: string, type: 'header' | 'added' | 'removed' | 'unchanged') => { + const lineEl = container.createSpan({ cls: `diff-line ${type}` }); + lineEl.textContent = text + '\n'; + }; + + createLine('--- Remote', 'header'); + createLine('+++ Local', 'header'); + createLine('', 'unchanged'); const maxLines = Math.max(localLines.length, remoteLines.length); @@ -84,17 +88,15 @@ export class SyncConflictModal extends Modal { if (remoteLine !== localLine) { if (remoteLine !== undefined) { - diff.push(`- ${remoteLine}`); + createLine(`- ${remoteLine}`, 'removed'); } if (localLine !== undefined) { - diff.push(`+ ${localLine}`); + createLine(`+ ${localLine}`, 'added'); } } else if (remoteLine !== undefined) { - diff.push(` ${remoteLine}`); + createLine(` ${remoteLine}`, 'unchanged'); } } - - return diff.join('\n'); } onClose() { diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 5a6e463..fb1c59a 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -385,7 +385,17 @@ export class SyncStatusView extends ItemView { diffContainer.addClass('hidden'); const diffPre = diffContainer.createEl('pre'); - diffPre.createEl('code', { text: fileStatus.diff }); + + const diffLines = fileStatus.diff.split('\n'); + for (const line of diffLines) { + let type: 'header' | 'added' | 'removed' | 'unchanged' = 'unchanged'; + if (line.startsWith('---') || line.startsWith('+++')) type = 'header'; + else if (line.startsWith('+ ')) type = 'added'; + else if (line.startsWith('- ')) type = 'removed'; + + const lineEl = diffPre.createSpan({ cls: `diff-line ${type}` }); + lineEl.textContent = line + '\n'; + } diffToggle.addEventListener('click', () => { if (diffContainer.hasClass('hidden')) { @@ -473,10 +483,16 @@ export class SyncStatusView extends ItemView { try { const allFiles = this.app.vault.getFiles(); - const files = this.plugin.filterFilesByVaultFolder(allFiles); + let files = this.plugin.filterFilesByVaultFolder(allFiles); // Get remote files - const remoteFiles = await this.plugin.gitService.listFiles(this.plugin.settings.branch); + let remoteFiles = await this.plugin.gitService.listFiles(this.plugin.settings.branch); + + // Load .gitignore rules based on remote files, then filter out ignored paths + await this.plugin.gitignoreManager.loadGitignores(remoteFiles); + remoteFiles = remoteFiles.filter(path => !this.plugin.gitignoreManager.isIgnored(path)); + files = files.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path)); + const localFilePaths = new Set(files.map(f => f.path)); // Use ALL local files (not just vaultFolder-filtered) for remote-only detection, // so files like .claude/skills/*.md that live outside vaultFolder are not @@ -530,7 +546,13 @@ export class SyncStatusView extends ItemView { this.renderView(); // Check status for local files with progress - const filesToCheck: Array = [...files, ...extraFilesToCheck]; + let filesToCheck: Array = [...files, ...extraFilesToCheck]; + + // Final filter for extra files to check (some might be ignored) + filesToCheck = filesToCheck.filter(f => { + const path = typeof f === 'string' ? f : f.path; + return !this.plugin.gitignoreManager.isIgnored(path); + }); const totalFiles = filesToCheck.length; let checkedFiles = 0; @@ -567,9 +589,9 @@ export class SyncStatusView extends ItemView { const path = isString ? fileOrPath : fileOrPath.path; const file = isString ? undefined : fileOrPath; - const localContent = isString - ? await this.app.vault.adapter.read(path) - : await this.app.vault.read(file as TFile); + const localContent = typeof fileOrPath === 'string' + ? await this.app.vault.adapter.read(fileOrPath) + : await this.app.vault.read(fileOrPath); const remote = await this.plugin.gitService.getFile(path, this.plugin.settings.branch); diff --git a/styles.css b/styles.css index e4f93ce..c54be15 100644 --- a/styles.css +++ b/styles.css @@ -479,3 +479,32 @@ If your plugin does not need CSS, delete this file. gap: 10px; } +/* GitHub style diff viewer */ +.diff-line { + display: block; + padding: 0 4px; + font-family: var(--font-monospace); + white-space: pre-wrap; + word-break: break-all; +} + +.diff-line.added { + background-color: rgba(46, 160, 67, 0.2); + color: var(--text-normal); +} + +.diff-line.removed { + background-color: rgba(248, 81, 73, 0.2); + color: var(--text-normal); +} + +.diff-line.unchanged { + color: var(--text-muted); +} + +.diff-line.header { + color: var(--text-accent); + font-weight: bold; + margin-bottom: 4px; +} +