feat: Support .gitignore filtering and GitHub-style diff viewer

This commit is contained in:
ClaudiaFang 2026-04-24 12:41:36 +00:00
parent 5d9cd3345d
commit cbc55d502d
8 changed files with 221 additions and 36 deletions

48
package-lock.json generated
View file

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

View file

@ -40,6 +40,7 @@
"vitest": "^4.1.2"
},
"dependencies": {
"ignore": "^7.0.5",
"obsidian": "latest"
}
}

View file

@ -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<string, Ignore> = 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<void> {
this.ignoreMap.clear();
// 1. Identify all unique directories that contain a .gitignore
const gitignorePaths = new Set<string>();
// 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;
}
}

View file

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

View file

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

View file

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

View file

@ -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<TFile | string> = [...files, ...extraFilesToCheck];
let filesToCheck: Array<TFile | string> = [...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);

View file

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