diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a433ad1..58833f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ permissions: contents: write issues: write pull-requests: write + attestations: write + id-token: write on: push: diff --git a/.releaserc.json b/.releaserc.json index 9e031a3..2c02683 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -59,8 +59,7 @@ [ "@semantic-release/exec", { - "prepareCmd": "node -e \"const fs=require('fs'); const m=JSON.parse(fs.readFileSync('manifest.json')); m.version='${nextRelease.version}'; fs.writeFileSync('manifest.json', JSON.stringify(m,null,'\\t')); const v=JSON.parse(fs.readFileSync('versions.json')); v['${nextRelease.version}']=m.minAppVersion; fs.writeFileSync('versions.json', JSON.stringify(v,null,'\\t'));\"", - "publishCmd": "zip -j git-files-sync-${nextRelease.version}.zip main.js manifest.json styles.css" + "prepareCmd": "node -e \"const fs=require('fs'); const m=JSON.parse(fs.readFileSync('manifest.json')); m.version='${nextRelease.version}'; fs.writeFileSync('manifest.json', JSON.stringify(m,null,'\\t')); const v=JSON.parse(fs.readFileSync('versions.json')); v['${nextRelease.version}']=m.minAppVersion; fs.writeFileSync('versions.json', JSON.stringify(v,null,'\\t'));\"" } ], [ @@ -76,8 +75,7 @@ "assets": [ { "path": "main.js" }, { "path": "manifest.json" }, - { "path": "styles.css" }, - { "path": "git-files-sync-*.zip", "label": "Plugin Package (Zip)" } + { "path": "styles.css" } ] } ] diff --git a/package-lock.json b/package-lock.json index 1591ab1..90756d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "ignore": "^7.0.5", - "obsidian": "*" + "obsidian": "latest" }, "devDependencies": { "@eslint/js": "9.30.1", @@ -19,6 +19,7 @@ "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.6", + "@types/jsdom": "^28.0.3", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.2", @@ -2633,6 +2634,26 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "28.0.3", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.3.tgz", + "integrity": "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^7.21.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz", + "integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2680,6 +2701,13 @@ "@types/estree": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", diff --git a/package.json b/package.json index ab884d5..fc9b611 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.6", + "@types/jsdom": "^28.0.3", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.2", diff --git a/src/logic/gitignore-manager.ts b/src/logic/gitignore-manager.ts index 5e30996..9552057 100644 --- a/src/logic/gitignore-manager.ts +++ b/src/logic/gitignore-manager.ts @@ -1,6 +1,7 @@ import ignore, { Ignore } from 'ignore'; import { App } from 'obsidian'; import { GitServiceInterface } from '../services/git-service-interface'; +import { logger } from '../utils/logger'; export class GitignoreManager { private readonly app: App; @@ -8,56 +9,121 @@ export class GitignoreManager { private readonly branch: string; private readonly rootPath: string; + private readonly vaultFolder: string; // Maps directory path (empty string for root) to Ignore instance private readonly ignoreMap: Map = new Map(); - constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string) { + constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string, vaultFolder: string = '') { this.app = app; this.gitService = gitService; this.branch = branch; this.rootPath = rootPath.replace(/^\/|\/$/g, ''); + this.vaultFolder = vaultFolder.replace(/^\/|\/$/g, ''); + } + + private getNormalizedPath(path: string): string { + if (!this.vaultFolder) return path; + const folderPath = this.vaultFolder + '/'; + if (path.startsWith(folderPath)) { + return path.substring(folderPath.length); + } + if (path === this.vaultFolder) return ''; + return path; } /** * Discovers and parses .gitignore files from the local filesystem and remote repository. + * Local files take priority; remote supplements anything not found locally. */ async loadGitignores(): Promise { this.ignoreMap.clear(); - // 1. Fetch all gitignore paths from the entire repo tree - let gitignorePaths: string[] = []; + // 1. Collect all potential gitignore paths + const gitignorePaths = new Set(); + + // a. Repo root + gitignorePaths.add('.gitignore'); + + // b. All parent directories of rootPath + if (this.rootPath) { + const parts = this.rootPath.split('/'); + let current = ''; + for (const part of parts) { + if (current) current += '/'; + current += part; + gitignorePaths.add(current + '/.gitignore'); + } + } + + // c. Scan local vault for .gitignore files (vault-relative → repo-relative) + await this.scanLocalGitignores(gitignorePaths); + + // d. Supplement with remote repo's gitignore listing (filtered to rootPath) try { - gitignorePaths = await this.gitService.getRepoGitignores(this.branch); + const remotePaths = await this.gitService.getRepoGitignores(this.branch); + for (const p of remotePaths) gitignorePaths.add(p); } catch (e) { - console.warn('Failed to fetch repo gitignores', e); - // Fallback to at least checking the root - gitignorePaths = ['.gitignore']; + logger.warn('Failed to fetch repo gitignores', e); } - // 2. Fetch and parse each .gitignore + // 2. Load content and build ignore instances for (const fullGitignorePath of gitignorePaths) { - const dirPath = fullGitignorePath === '.gitignore' ? '' : fullGitignorePath.slice(0, -('.gitignore'.length + 1)); + const dirPath = fullGitignorePath === '.gitignore' + ? '' + : fullGitignorePath.slice(0, -(('.gitignore'.length) + 1)); const content = await this.getGitignoreContent(fullGitignorePath); - if (content) { - const ig = ignore().add(content); - this.ignoreMap.set(dirPath, ig); + this.ignoreMap.set(dirPath, ignore().add(content)); } } } + private async scanLocalGitignores(out: Set): Promise { + // Only scan within vaultFolder + await this.scanDir(this.vaultFolder, out); + } + + private async scanDir(vaultDir: string, out: Set): Promise { + try { + const listing = await this.app.vault.adapter.list(vaultDir || ''); + for (const filePath of listing.files) { + if (filePath === '.gitignore' || filePath.endsWith('/.gitignore')) { + const normalized = this.getNormalizedPath(filePath); + const repoPath = this.rootPath ? `${this.rootPath}/${normalized}` : normalized; + out.add(repoPath); + } + } + for (const subFolder of listing.folders) { + await this.scanDir(subFolder, out); + } + } catch { /* adapter.list may be unavailable in some environments */ } + } + + private getVaultPath(normalizedPath: string): string { + if (!this.vaultFolder) return normalizedPath; + if (!normalizedPath) return this.vaultFolder; + return this.vaultFolder + '/' + normalizedPath; + } + private async getGitignoreContent(fullGitignorePath: string): Promise { let content: string | undefined; // Determine local path relative to vault root - let localPath: string | null = null; + let normalized: string | null; if (!this.rootPath) { - localPath = fullGitignorePath; + normalized = fullGitignorePath; } else if (fullGitignorePath === this.rootPath + '/.gitignore' || fullGitignorePath.startsWith(this.rootPath + '/')) { - localPath = fullGitignorePath.substring(this.rootPath.length + 1); + normalized = fullGitignorePath.substring(this.rootPath.length + 1); + } else if (fullGitignorePath === '.gitignore') { + // Repo root gitignore might not be in the vault sync area + normalized = null; + } else { + normalized = null; } + const localPath = normalized !== null ? this.getVaultPath(normalized) : null; + // Try local first if it's within the vault if (localPath) { try { @@ -65,7 +131,7 @@ export class GitignoreManager { content = await this.app.vault.adapter.read(localPath); } } catch (e) { - console.warn(`Failed to read local ${localPath}`, e); + logger.warn(`Failed to read local ${localPath}`, e); } } @@ -74,7 +140,7 @@ export class GitignoreManager { try { const remoteFile = await this.gitService.getFile('/' + fullGitignorePath, this.branch); if (remoteFile?.content) { - content = remoteFile.content; + content = remoteFile.content as string; } } catch { // It's okay if some gitignores fail to fetch diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index 2264d2b..a2feb60 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -2,6 +2,8 @@ import { TFile, App, Notice } from 'obsidian'; import { GitServiceInterface } from '../services/git-service-interface'; import { GitLabFilesPushSettings, getServiceName } from '../settings'; import { SyncConflictModal } from '../ui/SyncConflictModal'; +import { logger } from '../utils/logger'; +import { isBinaryPath, contentsEqual } from '../utils/path'; export class SyncManager { private readonly app: App; @@ -29,12 +31,23 @@ export class SyncManager { await this.saveSettings(); } + private getNormalizedPath(path: string): string { + if (!this.settings.vaultFolder) return path; + const folderPath = this.settings.vaultFolder + '/'; + if (path.startsWith(folderPath)) { + return path.substring(folderPath.length); + } + if (path === this.settings.vaultFolder) return ''; + return path; + } + updateGitService(gitService: GitServiceInterface): void { this.gitService = gitService; } async pushFile(fileOrPath: TFile | string) { const { path, name, isString } = this.getFileInfo(fileOrPath); + const repoPath = this.getNormalizedPath(path); if (!await this.checkFileExists(path, isString)) { new Notice(`File ${name} no longer exists in vault.`); @@ -44,21 +57,27 @@ export class SyncManager { const content = await this.getFileContent(fileOrPath); try { // Check if this is a renamed file - let renamedFrom = null; if (!isString && fileOrPath instanceof TFile) { - renamedFrom = this.detectRename(fileOrPath); + const renamedFrom = this.detectRename(fileOrPath); if (renamedFrom) { await this.handleRename(fileOrPath, renamedFrom, content); return; } } - // Conflict detection - const remote = await this.gitService.getFile(path, this.settings.branch); + // Conflict detection & equality check + const remote = await this.gitService.getFile(repoPath, this.settings.branch); + + if (remote.sha && this.contentsEqual(content, remote.content)) { + await this.updateMetadata(path, remote.sha); + new Notice(`${name} is already up to date.`); + return; + } + const lastSynced = this.settings.syncMetadata[path]; if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) { - new SyncConflictModal(this.app, name, content, remote.content, (choice) => { + new SyncConflictModal(this.app, name, content as string, remote.content as string, (choice) => { void (async () => { try { const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; @@ -99,24 +118,28 @@ export class SyncManager { return null; } - private async handleRename(file: TFile, oldPath: string, content: string): Promise { + private async handleRename(file: TFile, oldPath: string, content: string | ArrayBuffer): Promise { try { + const repoPath = this.getNormalizedPath(file.path); + const oldRepoPath = this.getNormalizedPath(oldPath); + // Push the file to the new location - await this.gitService.pushFile( - file.path, + const result = await this.gitService.pushFile( + repoPath, content, this.settings.branch, - `Rename ${oldPath} to ${file.path}`, + `Rename ${oldRepoPath} to ${repoPath}`, undefined ); - // Delete the old file from remote - // Note: GitLab and GitHub APIs handle this differently - // For now, we'll just update metadata and let the user manually delete if needed - // Update metadata - const newRemote = await this.gitService.getFile(file.path, this.settings.branch); - await this.updateMetadata(file.path, newRemote.sha); + let newSha = result.sha; + if (!newSha) { + const newRemote = await this.gitService.getFile(repoPath, this.settings.branch); + newSha = newRemote.sha; + } + + if (newSha) await this.updateMetadata(file.path, newSha); // Remove old metadata delete this.settings.syncMetadata[oldPath]; @@ -125,12 +148,14 @@ export class SyncManager { new Notice(`Renamed and pushed ${file.name} to ${this.serviceName}\nNote: Old file at ${oldPath} may need manual deletion from remote`); } catch (e) { this.handleError('Failed to handle rename', e); + throw e; // Rethrow for batch processing } } - private async performPush(file: {path: string, name: string}, content: string, existingSha?: string, silent = false) { - await this.gitService.pushFile( - file.path, + private async performPush(file: {path: string, name: string}, content: string | ArrayBuffer, existingSha?: string, silent = false) { + const repoPath = this.getNormalizedPath(file.path); + const result = await this.gitService.pushFile( + repoPath, content, this.settings.branch, `Update ${file.name} from Obsidian`, @@ -138,17 +163,23 @@ export class SyncManager { ); // Update metadata - const newRemote = await this.gitService.getFile(file.path, this.settings.branch); - await this.updateMetadata(file.path, newRemote.sha); + let newSha = result.sha; + if (!newSha) { + const newRemote = await this.gitService.getFile(repoPath, this.settings.branch); + newSha = newRemote.sha; + } + + if (newSha) await this.updateMetadata(file.path, newSha); if (!silent) new Notice(`Pushed ${file.name} to ${this.serviceName}`); } async pullFile(fileOrPath: TFile | string) { const { path, name, isString } = this.getFileInfo(fileOrPath); + const repoPath = this.getNormalizedPath(path); try { - const remote = await this.gitService.getFile(path, this.settings.branch); + const remote = await this.gitService.getFile(repoPath, this.settings.branch); if (!remote.sha) { new Notice(`File ${name} not found on remote.`); return; @@ -158,7 +189,7 @@ export class SyncManager { const localContent = exists ? await this.getFileContent(fileOrPath) : null; const lastSynced = this.settings.syncMetadata[path]; - if (exists && localContent === remote.content) { + if (exists && localContent !== null && this.contentsEqual(localContent, remote.content)) { // Still update metadata even if content matches await this.updateMetadata(path, remote.sha); new Notice(`${name} is already up to date.`); @@ -167,12 +198,12 @@ export class SyncManager { // Conflict detection for pull (only if local exists) if (exists && remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) { - new SyncConflictModal(this.app, name, localContent || '', remote.content, (choice) => { + new SyncConflictModal(this.app, name, (localContent as string) || '', remote.content as string, (choice) => { void (async () => { try { const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; if (choice === 'local') { - await this.performPush({ path, name }, localContent || '', remote.sha); + await this.performPush(fileRep, localContent || '', remote.sha); } else { await this.performPull(fileRep, remote.content, remote.sha); } @@ -191,13 +222,31 @@ export class SyncManager { } } - private async performPull(file: TFile | {path: string, name: string}, remoteContent: string, remoteSha: string, silent = false) { + private contentsEqual(a: string | ArrayBuffer, b: string | ArrayBuffer): boolean { + return contentsEqual(a, b); + } + + private isBinary(path: string): boolean { + return isBinaryPath(path); + } + + private async performPull(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer, remoteSha: string, silent = false) { await this.ensureParentDirs(file.path); - - if (file instanceof TFile) { - await this.app.vault.modify(file, remoteContent); + + if (typeof remoteContent !== 'string') { + // remoteContent is ArrayBuffer + if (file instanceof TFile) { + await this.app.vault.modifyBinary(file, remoteContent); + } else { + await this.app.vault.adapter.writeBinary(file.path, remoteContent); + } } else { - await this.app.vault.adapter.write(file.path, remoteContent); + // remoteContent is string + if (file instanceof TFile) { + await this.app.vault.modify(file, remoteContent); + } else { + await this.app.vault.adapter.write(file.path, remoteContent); + } } // Update metadata @@ -228,7 +277,7 @@ export class SyncManager { } private handleError(message: string, error: unknown): void { - console.error(message, error); + logger.error(message, error); const detail = error instanceof Error ? error.message : String(error); new Notice(`${message}: ${detail}`); } @@ -256,14 +305,15 @@ export class SyncManager { onProgress?.(i + 1, files.length, name); try { + let performed = false; if (op === 'push') { - await this.processSingleBatchPush(fileOrPath, path, name, isString); + performed = await this.processSingleBatchPush(fileOrPath, path, name, isString); } else { - await this.processSingleBatchPull(fileOrPath, path, name, isString); + performed = await this.processSingleBatchPull(fileOrPath, path, name, isString); } - results.success++; + if (performed) results.success++; } catch (e) { - console.error(`Failed to ${op} ${path}:`, e); + logger.error(`Failed to ${op} ${path}:`, e); results.failed++; results.errors.push({ file: path, error: e instanceof Error ? e.message : String(e) }); } @@ -299,35 +349,62 @@ export class SyncManager { return !!this.app.vault.getFileByPath(path); } - private async getFileContent(fileOrPath: TFile | string): Promise { + private async getFileContent(fileOrPath: TFile | string): Promise { + const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + const binary = this.isBinary(path); + if (typeof fileOrPath === 'string') { - return await this.app.vault.adapter.read(fileOrPath); + return binary + ? await this.app.vault.adapter.readBinary(fileOrPath) + : await this.app.vault.adapter.read(fileOrPath); } - return await this.app.vault.read(fileOrPath); + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); } - private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean) { + private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { if (!await this.checkFileExists(path, isString)) throw new Error('File no longer exists'); const content = await this.getFileContent(fileOrPath); + const repoPath = this.getNormalizedPath(path); // Rename detection if (!isString && fileOrPath instanceof TFile) { const renamedFrom = this.detectRename(fileOrPath); if (renamedFrom) { await this.handleRename(fileOrPath, renamedFrom, content); - return; + return true; } } - const remote = await this.gitService.getFile(path, this.settings.branch); + const remote = await this.gitService.getFile(repoPath, this.settings.branch); + + // Skip if already in sync + if (remote.sha && this.contentsEqual(content, remote.content)) { + await this.updateMetadata(path, remote.sha); + return false; + } + await this.performPush({ path, name }, content, remote.sha || undefined, true); + return true; } - private async processSingleBatchPull(fileOrPath: TFile | string, path: string, name: string, isString: boolean) { - const remote = await this.gitService.getFile(path, this.settings.branch); + private async processSingleBatchPull(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { + const repoPath = this.getNormalizedPath(path); + const remote = await this.gitService.getFile(repoPath, this.settings.branch); if (!remote.sha) throw new Error('File not found in remote'); + const exists = await this.checkFileExists(path, isString); + if (exists) { + const localContent = await this.getFileContent(fileOrPath); + if (this.contentsEqual(localContent, remote.content)) { + await this.updateMetadata(path, remote.sha); + return false; + } + } + const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; await this.performPull(fileRep, remote.content, remote.sha, true); + return true; } } diff --git a/src/main.ts b/src/main.ts index 261f4c5..d228b51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ 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'; +import { logger } from './utils/logger'; import { ConfirmModal } from './ui/ConfirmModal'; export default class GitLabFilesPush extends Plugin { @@ -36,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.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder); 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 () => { @@ -143,7 +144,7 @@ export default class GitLabFilesPush extends Plugin { await this.gitService.listFiles(this.settings.branch); await this.gitignoreManager.loadGitignores(); - files = files.filter(f => !this.gitignoreManager.isIgnored(f.path)); + files = files.filter(f => !this.gitignoreManager.isIgnored(this.getNormalizedPath(f.path))); if (files.length === 0) { new Notice(`No files to ${op} in the configured vault folder`); @@ -171,11 +172,11 @@ export default class GitLabFilesPush extends Plugin { progressNotice.hide(); if (results.errors.length > 0) { - console.error(`${op} errors:`, results.errors); + logger.error(`${op} errors:`, results.errors); } } catch (e) { progressNotice.hide(); - console.error(e); + logger.error(String(e)); new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`); } } @@ -189,6 +190,28 @@ export default class GitLabFilesPush extends Plugin { return files.filter(file => file.path.startsWith(folderPath) || file.path === this.settings.vaultFolder); } + filterPathByVaultFolder(path: string): boolean { + if (!this.settings.vaultFolder) return true; + const folderPath = this.settings.vaultFolder + '/'; + return path.startsWith(folderPath) || path === this.settings.vaultFolder; + } + + getNormalizedPath(path: string): string { + if (!this.settings.vaultFolder) return path; + const folderPath = this.settings.vaultFolder + '/'; + if (path.startsWith(folderPath)) { + return path.substring(folderPath.length); + } + if (path === this.settings.vaultFolder) return ''; + return path; + } + + getVaultPath(normalizedPath: string): string { + if (!this.settings.vaultFolder) return normalizedPath; + if (!normalizedPath) return this.settings.vaultFolder; + return this.settings.vaultFolder + '/' + normalizedPath; + } + initializeGitService(): void { if (this.settings.serviceType === 'gitlab') { const service = new GitLabService(); diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 453605f..3cd997e 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -1,7 +1,8 @@ import { requestUrl, RequestUrlResponse } from 'obsidian'; +import { logger } from '../utils/logger'; export interface GitFile { - content: string; + content: string | ArrayBuffer; sha: string; } @@ -40,7 +41,7 @@ export abstract class BaseGitService { /** * Safely wraps requestUrl to handle potential throws from Obsidian and provide better error messages. */ - protected async safeRequest(url: string, method: string, body?: unknown, extraHeaders?: Record): Promise { + protected async safeRequest(url: string, method: string, body?: unknown, extraHeaders?: Record, silent = false): Promise { try { const headers: Record = { ...extraHeaders, @@ -60,12 +61,13 @@ export abstract class BaseGitService { if (response.status >= 400) { const errorMsg = this.parseErrorResponse(response); + if (!silent) logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg); throw new Error(`Git Service Error (${response.status}): ${errorMsg}`); } return response; } catch (error) { - console.error('Git Service Request Failed:', error); + if (!silent) logger.error('Git Service Request Failed:', error); if (error instanceof Error) throw error; throw new Error(`Network error or unexpected failure: ${String(error)}`); } @@ -83,32 +85,56 @@ export abstract class BaseGitService { } protected getFullPath(path: string): string { + // Leading / = absolute repo path; skip rootPath prefix entirely + if (path.startsWith('/')) return path.slice(1); if (!this.rootPath) return path; const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; - const cleanPath = path.startsWith('/') ? path.slice(1) : path; - return cleanRoot + cleanPath; + // Already contains rootPath prefix (path came from listFiles or vault IS repo root) + if (path.startsWith(cleanRoot)) return path; + return cleanRoot + path; + } + + protected isBinary(path: string): boolean { + const ext = path.split('.').pop()?.toLowerCase(); + if (!ext) return false; + const BINARY_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', '7z', 'rar', + 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi', 'wmv', 'webp', + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'epub', 'exe', 'dll', 'so', + 'ttf', 'woff', 'woff2', 'eot', 'wasm', 'dmg', 'iso' + ]); + return BINARY_EXTENSIONS.has(ext); } - protected encodeContent(content: string): string { - const bytes = new TextEncoder().encode(content); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - const byte = bytes[i]; - if (byte !== undefined) { - binary += String.fromCodePoint(byte); + protected encodeContent(content: string | ArrayBuffer): string { + if (typeof content === 'string') { + const bytes = new TextEncoder().encode(content); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte !== undefined) binary += String.fromCodePoint(byte); + } + return btoa(binary); + } else { + const bytes = new Uint8Array(content); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte !== undefined) binary += String.fromCodePoint(byte); } + return btoa(binary); } - return btoa(binary); } - protected decodeContent(base64: string): string { + protected decodeContent(base64: string, path: string): string | ArrayBuffer { const binary = atob(base64.replace(/\s/g, '')); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { const cp = binary.codePointAt(i); bytes[i] = cp !== undefined ? cp : 0; } - return new TextDecoder().decode(bytes); + + return this.isBinary(path) ? bytes.buffer : new TextDecoder().decode(bytes); } protected handleFileNotFound(e: unknown): GitFile { @@ -119,13 +145,17 @@ export abstract class BaseGitService { } async getRepoGitignores(branch: string): Promise { - const allFiles = await this.listFiles(branch); - return allFiles.filter(p => p.endsWith('.gitignore')); + try { + const allFiles = await this.listFiles(branch, false); // Fetch ALL files to find gitignores + return allFiles.filter(p => p.endsWith('.gitignore')); + } catch { + return []; + } } abstract getFile(path: string, branch: string): Promise; - abstract pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise; - abstract listFiles(branch: string): Promise; + abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>; + abstract listFiles(branch: string, useFilter?: boolean): Promise; abstract deleteFile(path: string, branch: string, message: string): Promise; abstract testConnection(): Promise; } diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index c2e7f07..3357260 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -1,9 +1,14 @@ +export interface GitFile { + content: string | ArrayBuffer; + sha: string; +} + export interface GitServiceInterface { updateConfig(...args: unknown[]): void; - getFile(path: string, branch: string): Promise<{ content: string; sha: string }>; - pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise; + getFile(path: string, branch: string): Promise; + pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>; testConnection(): Promise; - listFiles(branch: string, path?: string): Promise; + listFiles(branch: string, useFilter?: boolean): Promise; deleteFile(path: string, branch: string, commitMessage: string): Promise; getRepoGitignores(branch: string): Promise; } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 9d8070c..2d661b7 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -1,5 +1,6 @@ import { GitServiceInterface } from './git-service-interface'; import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base'; +import { logger } from '../utils/logger'; export class GitHubService extends BaseGitService implements GitServiceInterface { private owner: string = ''; @@ -28,7 +29,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface const data = response.json as GitHubContentResponse; return { - content: this.decodeContent(data.content), + content: this.decodeContent(data.content, path), sha: data.sha }; } catch (e) { @@ -36,7 +37,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface } } - async pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise { + async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); const body = { message, @@ -46,23 +47,30 @@ export class GitHubService extends BaseGitService implements GitServiceInterface }; const response = await this.safeRequest(url, 'PUT', body); - const data = response.json as { content: { path: string } }; - return data.content.path; + const data = response.json as { content: { path: string, sha: string } }; + return { path: data.content.path, sha: data.content.sha }; } - async listFiles(branch: string): Promise { + async listFiles(branch: string, useFilter = true): Promise { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; 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.'); + logger.warn('GitHub tree result is truncated. Some files might not be shown.'); } - return data.tree + const files = data.tree .filter(item => item.type === 'blob') - .map(item => item.path) - .filter(p => !this.rootPath || p.startsWith(this.rootPath)); + .map(item => item.path); + + if (!useFilter) return files; + + return files.filter(p => { + if (!this.rootPath) return true; + const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; + return p === this.rootPath || p.startsWith(cleanRoot); + }); } async deleteFile(path: string, branch: string, message: string): Promise { diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index d896ba3..281e0ef 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -30,7 +30,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface const data = response.json as GitLabFileResponse; return { - content: this.decodeContent(data.content), + content: this.decodeContent(data.content, path), sha: data.last_commit_id }; } catch (e) { @@ -38,7 +38,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface } } - async pushFile(path: string, content: string, branch: string, message: string, sha?: string): Promise { + async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); const body = { branch, @@ -51,10 +51,10 @@ export class GitLabService extends BaseGitService implements GitServiceInterface const method = sha ? 'PUT' : 'POST'; const response = await this.safeRequest(url, method, body); const data = response.json as GitLabFileResponse; - return data.file_path; + return { path: data.file_path }; } - async listFiles(branch: string): Promise { + async listFiles(branch: string, useFilter = true): Promise { const encodedProjectId = encodeURIComponent(this.projectId); let allPaths: string[] = []; let page = 1; @@ -69,10 +69,18 @@ export class GitLabService extends BaseGitService implements GitServiceInterface const paths = data .filter(item => item.type === 'blob') - .map(item => item.path) - .filter(p => !this.rootPath || p.startsWith(this.rootPath)); + .map(item => item.path); - allPaths = allPaths.concat(paths); + if (useFilter) { + const filtered = paths.filter(p => { + if (!this.rootPath) return true; + const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; + return p === this.rootPath || p.startsWith(cleanRoot); + }); + allPaths = allPaths.concat(filtered); + } else { + allPaths = allPaths.concat(paths); + } if (data.length < perPage) break; page++; diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 8ef30f5..110354b 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -2,44 +2,19 @@ import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'ob import GitLabFilesPush from '../main'; import { getServiceName } from '../settings'; import { ConfirmModal } from './ConfirmModal'; +import { logger } from '../utils/logger'; +import { type FileStatus, type FilterValue } from './types'; +import { renderActionBar } from './components/ActionBar'; +import { renderFileItem, type FileItemCallbacks } from './components/FileListItem'; +import { isBinaryPath, contentsEqual } from '../utils/path'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; -interface FileStatus { - file?: TFile; - path: string; - status: 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'checking'; - localContent?: string; - remoteContent?: string; - remoteSha?: string; - diff?: string; -} - -interface DiffSide { - lineNum: number | null; - content: string | null; - type: 'removed' | 'added' | 'unchanged' | 'empty'; -} - -interface DiffRow { - left: DiffSide; - right: DiffSide; -} - -type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; - -type DiffOpType = 'unchanged' | 'removed' | 'added'; - -interface DiffOp { - type: DiffOpType; - li: number; - ri: number; -} - export class SyncStatusView extends ItemView { plugin: GitLabFilesPush; private readonly fileStatuses: Map = new Map(); private isRefreshing = false; + private refreshProgress = { current: 0, total: 0 }; private statusFilter: FilterValue = 'all'; private readonly selectedFiles: Set = new Set(); private lastSyncTime: number = 0; @@ -69,16 +44,46 @@ export class SyncStatusView extends ItemView { this.renderInfoStrip(container); this.renderTabs(container); - this.renderActionBar(container); + this.renderActionBarSection(container); const listEl = container.createDiv({ cls: 'ssv-list' }); - if (this.fileStatuses.size === 0) { + + if (this.isRefreshing) { + this.renderProgressBar(listEl); + this.renderCheckedFilesDuringRefresh(listEl); + } else if (this.fileStatuses.size === 0) { listEl.createDiv({ cls: 'ssv-empty', text: 'Click "Refresh" to check sync status' }); } else { this.renderFileList(listEl); } } + private renderProgressBar(container: HTMLElement): void { + const { current, total } = this.refreshProgress; + const pct = total > 0 ? Math.round((current / total) * 100) : 0; + const prog = container.createDiv({ cls: 'ssv-progress' }); + prog.createDiv({ + cls: 'ssv-progress-text', + text: total > 0 ? `Checking files… ${current}/${total} (${pct}%)` : 'Checking files…' + }); + const bar = prog.createDiv({ cls: 'ssv-progress-bar' }); + bar.createDiv({ cls: 'ssv-progress-fill' }).setAttr('style', `width: ${pct}%`); + } + + private renderCheckedFilesDuringRefresh(container: HTMLElement): void { + const checked = Array.from(this.fileStatuses.values()) + .filter(s => s.status !== 'checking') + .filter(s => this.statusFilter === 'all' || s.status === this.statusFilter); + if (checked.length === 0) return; + const checkedList = container.createDiv({ cls: 'ssv-list-checked' }); + const cb = this.fileItemCallbacks(); + for (const fs of checked) { + renderFileItem(checkedList, fs, this.selectedFiles.has(fs.path), cb); + } + } + + // ── Info strip ───────────────────────────────────────────────── + private renderInfoStrip(container: HTMLElement): void { const el = container.createDiv({ cls: 'ssv-info' }); const serviceName = getServiceName(this.plugin.settings); @@ -87,8 +92,7 @@ export class SyncStatusView extends ItemView { if (!Platform.isMobile) { el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - const branchEl = el.createSpan({ cls: 'ssv-info-item' }); - branchEl.textContent = `⎇ ${this.plugin.settings.branch}`; + el.createSpan({ cls: 'ssv-info-item' }).textContent = `⎇ ${this.plugin.settings.branch}`; } if (this.plugin.settings.vaultFolder) { @@ -100,11 +104,15 @@ export class SyncStatusView extends ItemView { el.createSpan({ cls: 'ssv-info-sep', text: '·' }); el.createSpan({ cls: 'ssv-info-time', - text: Platform.isMobile ? new Date(this.lastSyncTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : `Last sync: ${new Date(this.lastSyncTime).toLocaleTimeString()}` + text: Platform.isMobile + ? new Date(this.lastSyncTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : `Last sync: ${new Date(this.lastSyncTime).toLocaleTimeString()}` }); } } + // ── Filter tabs ───────────────────────────────────────────────── + private renderTabs(container: HTMLElement): void { const all = Array.from(this.fileStatuses.values()); const counts: Record = { @@ -143,70 +151,50 @@ export class SyncStatusView extends ItemView { } } - private renderActionBar(container: HTMLElement): void { - const { visible, canPush, canPull, canDelete, allSelected } = this.getActionBarState(); - const bar = container.createDiv({ cls: 'ssv-action-bar' }); - - this.renderRefreshButton(bar); - - if (this.fileStatuses.size > 0) { - bar.createDiv({ cls: 'ssv-bar-spacer' }); - this.renderSelectAllRow(bar, allSelected, visible); - this.renderActionButtons(bar, canPush, canPull, canDelete); - } - } + // ── Action bar ───────────────────────────────────────────────── - private getActionBarState() { + private renderActionBarSection(container: HTMLElement): void { const all = Array.from(this.fileStatuses.values()); const visible = this.statusFilter === 'all' ? all : all.filter(s => s.status === this.statusFilter); - const selected = Array.from(this.selectedFiles).map(p => this.fileStatuses.get(p)).filter(Boolean) as FileStatus[]; - - return { - visible, - canPush: selected.filter(s => s.file && (s.status === 'modified' || s.status === 'unsynced')).length, - canPull: selected.filter(s => s.status === 'modified' || s.status === 'remote-only').length, - canDelete: selected.filter(s => s.file || s.status === 'remote-only').length, - allSelected: visible.length > 0 && visible.every(s => this.selectedFiles.has(s.path)) - }; - } - - private renderRefreshButton(bar: HTMLElement): void { - const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); - btn.createSpan({ text: '↻' }); - btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' }); - setTooltip(btn, 'Refresh all statuses'); - btn.addEventListener('click', () => void this.refreshAllStatuses()); - } + const selected = Array.from(this.selectedFiles) + .map(p => this.fileStatuses.get(p)) + .filter(Boolean) as FileStatus[]; - private renderSelectAllRow(bar: HTMLElement, allSelected: boolean, visible: FileStatus[]): void { - const selectRow = bar.createDiv({ cls: 'ssv-select-row' }); - const cb = selectRow.createEl('input', { type: 'checkbox' }); - cb.checked = allSelected; - cb.indeterminate = this.selectedFiles.size > 0 && !allSelected; - selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' }); - cb.addEventListener('change', () => { - if (cb.checked) { - for (const s of visible) this.selectedFiles.add(s.path); - } else { - this.selectedFiles.clear(); - } - this.renderView(); + const allSelected = visible.length > 0 && visible.every(s => this.selectedFiles.has(s.path)); + + renderActionBar(container, { + hasFiles: this.fileStatuses.size > 0, + allSelected, + indeterminate: this.selectedFiles.size > 0 && !allSelected, + canPush: selected.filter(s => s.status === 'modified' || s.status === 'unsynced').length, + canPull: selected.filter(s => s.status === 'modified' || s.status === 'remote-only').length, + canDelete: selected.length, + }, { + onRefresh: () => void this.refreshAllStatuses(), + onSelectAll: (select) => { + if (select) { for (const s of visible) this.selectedFiles.add(s.path); } + else { this.selectedFiles.clear(); } + this.renderView(); + }, + onPush: () => void this.pushSelected(), + onPull: () => void this.pullSelected(), + onDelete: () => void this.deleteSelected(), }); } - private renderActionButtons(bar: HTMLElement, canPush: number, canPull: number, canDelete: number): void { - 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); - } + // ── File list ────────────────────────────────────────────────── - 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 fileItemCallbacks(): FileItemCallbacks { + return { + onSelect: (path, selected) => { + if (selected) this.selectedFiles.add(path); + else this.selectedFiles.delete(path); + this.renderView(); + }, + onPush: (fs) => void this.runSingleFile(fs, 'push'), + onPull: (fs) => void this.runSingleFile(fs, 'pull'), + onDelete: (fs) => void this.handleLocalDelete(fs), + }; } private renderFileList(container: HTMLElement): void { @@ -219,82 +207,14 @@ export class SyncStatusView extends ItemView { container.createDiv({ cls: 'ssv-empty', text: `No ${this.statusFilter} files` }); return; } - for (const fs of statuses) this.renderFileItem(container, fs); - } - - private renderFileItem(container: HTMLElement, fileStatus: FileStatus): void { - const { icon, label, iconCls, badgeCls, fileCls } = this.statusMeta(fileStatus.status); - const fileEl = container.createDiv({ cls: `ssv-file ${fileCls}` }); - - const row = fileEl.createDiv({ cls: 'ssv-file-row' }); - this.renderFileCheckbox(row, fileStatus); - - row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon }); - row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path }); - row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label }); - - if (fileStatus.status !== 'synced' && fileStatus.status !== 'checking') { - this.renderFileActions(fileEl, fileStatus); - } - } - - private renderFileCheckbox(row: HTMLElement, fileStatus: FileStatus): void { - const cb = row.createEl('input', { type: 'checkbox', cls: 'ssv-file-checkbox' }); - cb.checked = this.selectedFiles.has(fileStatus.path); - cb.addEventListener('change', () => { - if (cb.checked) { - this.selectedFiles.add(fileStatus.path); - } else { - this.selectedFiles.delete(fileStatus.path); - } - this.renderView(); - }); - } - - private renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus): void { - const actions = fileEl.createDiv({ cls: 'ssv-file-actions' }); - - if (fileStatus.status === 'modified' && fileStatus.diff) { - this.renderDiffToggleButton(actions, fileEl, fileStatus); - } - if ((fileStatus.status === 'modified' || fileStatus.status === 'unsynced') && fileStatus.file) { - this.renderActionButton(actions, '↑', ' Push', 'Push to remote', () => void this.runSingleFile(fileStatus, 'push'), 'push'); - } - - if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') { - this.renderActionButton(actions, '↓', ' Pull', 'Pull from remote', () => void this.runSingleFile(fileStatus, 'pull'), 'pull'); - } - - if (fileStatus.status === 'unsynced' && fileStatus.file) { - this.renderActionButton(actions, '✕', ' Remove', 'Delete local file', () => void this.handleLocalDelete(fileStatus), 'danger'); + const cb = this.fileItemCallbacks(); + for (const fs of statuses) { + renderFileItem(container, fs, this.selectedFiles.has(fs.path), cb); } } - private renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void { - const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); - diffBtn.createSpan({ text: '≡' }); - const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); - const diffEl = this.renderDiffPanel(fileEl, fileStatus); - setTooltip(diffBtn, 'Toggle diff view'); - diffBtn.addEventListener('click', () => { - const open = diffEl.hasClass('visible'); - diffEl.toggleClass('visible', !open); - btnLabel.setText(open ? ' Diff' : ' Hide'); - const firstChild = diffBtn.firstChild; - if (firstChild instanceof HTMLElement || firstChild instanceof Text) { - firstChild.textContent = open ? '≡' : '▴'; - } - }); - } - - private renderActionButton(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void { - const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` }); - btn.createSpan({ text: icon }); - btn.createSpan({ cls: 'ssv-btn-label', text: label }); - setTooltip(btn, tooltip); - btn.addEventListener('click', onClick); - } + // ── Single-file operations ────────────────────────────────────── private async handleLocalDelete(fileStatus: FileStatus): Promise { const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`); @@ -324,7 +244,8 @@ export class SyncStatusView extends ItemView { await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path); } - await new Promise(r => setTimeout(r, 500)); + // eslint-disable-next-line no-undef + await new Promise(r => activeWindow.setTimeout(r, 500)); await this.refreshFileStatus(fileStatus.file || fileStatus.path); this.renderView(); } catch (e) { @@ -334,196 +255,7 @@ export class SyncStatusView extends ItemView { } } - private statusMeta(status: FileStatus['status']) { - switch (status) { - case 'synced': return { icon: '✓', label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; - case 'modified': return { icon: '⚠', label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; - case 'unsynced': return { icon: '↑', label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; - case 'remote-only': return { icon: '↓', label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; - default: return { icon: '⟳', label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; - } - } - - // ── Side-by-side diff ───────────────────────────────────────── - - private renderDiffPanel(fileEl: HTMLElement, fileStatus: FileStatus): HTMLElement { - const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); - const rows = this.computeSideBySideDiff(fileStatus.remoteContent ?? '', fileStatus.localContent ?? ''); - - // Side-by-side (shown on wide containers via container query) - const splitEl = diffEl.createDiv({ cls: 'ssv-diff-split' }); - const grid = splitEl.createDiv({ cls: 'ssv-diff-grid' }); - grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' }); - grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' }); - for (const row of rows) { - this.renderDiffCell(grid, row.left); - this.renderDiffCell(grid, row.right); - } - - // Unified (shown on narrow containers / mobile via container query) - const unifiedEl = diffEl.createEl('pre', { cls: 'ssv-diff-unified' }); - for (const row of rows) { - const { left, right } = row; - if (left.type === 'removed') { - unifiedEl.createSpan({ cls: 'ssv-u-line removed' }).textContent = `- ${left.content ?? ''}\n`; - } - if (right.type === 'added') { - unifiedEl.createSpan({ cls: 'ssv-u-line added' }).textContent = `+ ${right.content ?? ''}\n`; - } - if (left.type === 'unchanged') { - unifiedEl.createSpan({ cls: 'ssv-u-line unchanged' }).textContent = ` ${left.content ?? ''}\n`; - } - } - - return diffEl; - } - - private renderDiffCell(grid: HTMLElement, side: DiffSide): void { - const cell = grid.createDiv({ cls: `ssv-diff-cell ${side.type}` }); - cell.createSpan({ cls: 'ssv-diff-ln' }).textContent = side.lineNum === null ? '' : String(side.lineNum); - if (side.content !== null) { - cell.createSpan({ cls: 'ssv-diff-code' }).textContent = side.content; - } - } - - private computeSideBySideDiff(remote: string, local: string): DiffRow[] { - const L = this.normalizeContent(remote).split('\n'); - const R = this.normalizeContent(local).split('\n'); - const m = L.length, n = R.length; - - if (m * n > 250_000 || (m + 1) * (n + 1) > 1_000_000) { - return this.simpleDiff(L, R); - } - - 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 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)]!) + 1 - : Math.max(dp[(i - 1) * W + j]!, dp[i * W + (j - 1)]!); - } - } - return dp; - } - - 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) { - const op = this.getNextDiffOp(L, R, dp, W, i, j); - ops.push(op); - [i, j] = this.updateIndices(op, i, j); - } - return ops.reverse(); - } - - 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) break; - - if (op.type === 'unchanged') { - rows.push(this.createUnchangedRow(op, L, R)); - k++; - } else { - 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[] = []; - const max = Math.max(L.length, R.length); - for (let i = 0; i < max; i++) { - const l = L[i], r = R[i]; - if (l === undefined) rows.push({ left: { lineNum: null, content: null, type: 'empty' }, right: { lineNum: i + 1, content: r ?? null, type: 'added' } }); - else if (r === undefined) rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: null, content: null, type: 'empty' } }); - else if (l === r) rows.push({ left: { lineNum: i + 1, content: l, type: 'unchanged' }, right: { lineNum: i + 1, content: r, type: 'unchanged' } }); - else rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: i + 1, content: r, type: 'added' } }); - } - return rows; - } - - // ── Batch / refresh operations (logic unchanged) ────────────── + // ── Batch / refresh operations ───────────────────────────────── async refreshAllStatuses(): Promise { if (this.isRefreshing) { @@ -533,82 +265,133 @@ export class SyncStatusView extends ItemView { this.isRefreshing = true; this.fileStatuses.clear(); - this.showProgressIndicator(); + this.renderView(); // Show initial progress state try { const files = await this.discoverFiles(); this.initializeFileStatuses(files.local); - const extra = await this.identifyExtraFiles(files.remote, files.localMap, files.allMap); + for (const hiddenPath of files.hiddenLocalPaths) { + this.fileStatuses.set(hiddenPath, { path: hiddenPath, status: 'checking' }); + } + const extra = await this.identifyExtraFiles(files.remoteMap, files.localMap, files.allMap); this.addExtraToStatuses(extra); + // Re-render info/tabs but keep progress bar (renderView handles this) this.renderView(); - const filesToCheck = this.getCheckableFiles(files.local, extra); + const filesToCheck = this.getCheckableFiles(files.local, extra, files.hiddenLocalPaths); await this.performStatusCheck(filesToCheck); this.lastSyncTime = Date.now(); + this.isRefreshing = false; // Set to false BEFORE final renderView this.renderView(); - new Notice(`Checked ${files.local.length} local + ${files.remote.length} remote files`); + new Notice(`Checked ${files.local.length + files.hiddenLocalPaths.size} local + ${files.remoteMap.size} remote files`); } catch (e) { - new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`); - } finally { this.isRefreshing = false; + this.renderView(); + new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`); } } - private showProgressIndicator(): void { - const container = this.containerEl.children[1]; - if (!container) return; - const listEl = container.querySelector('.ssv-list'); - if (!listEl) return; - listEl.empty(); - const prog = listEl.createDiv({ cls: 'ssv-progress' }); - prog.createDiv({ cls: 'ssv-progress-text', text: 'Checking files…' }); - const bar = prog.createDiv({ cls: 'ssv-progress-bar' }); - const fill = bar.createDiv({ cls: 'ssv-progress-fill' }); - fill.setAttr('style', 'width: 0%'); - } - private async discoverFiles() { const allFiles = this.app.vault.getFiles(); let local = this.plugin.filterFilesByVaultFolder(allFiles); - let remote = await this.plugin.gitService.listFiles(this.plugin.settings.branch); + const remoteFullPaths = await this.plugin.gitService.listFiles(this.plugin.settings.branch); await this.plugin.gitignoreManager.loadGitignores(); - remote = remote.filter(p => !this.plugin.gitignoreManager.isIgnored(p)); - local = local.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path)); + + // Map remote paths to vault paths + const remoteMap = new Map(); // vaultPath -> remoteFullPath + for (const remotePath of remoteFullPaths) { + const normalized = this.getNormalizedRemotePath(remotePath); + if (normalized === null) continue; // Not under rootPath + + const vaultPath = this.plugin.getVaultPath(normalized); + if (!this.plugin.gitignoreManager.isIgnored(normalized)) { + remoteMap.set(vaultPath, remotePath); + } + } + + local = local.filter(f => !this.plugin.gitignoreManager.isIgnored(this.plugin.getNormalizedPath(f.path))); + + // vault.getFiles() skips hidden dirs; scan them via adapter + const hiddenLocalPaths = await this.discoverHiddenLocalFiles(); + const filteredHiddenPaths = new Set( + hiddenLocalPaths + .filter(p => this.plugin.filterPathByVaultFolder(p)) + .filter(p => !this.plugin.gitignoreManager.isIgnored(this.plugin.getNormalizedPath(p))) + ); return { local, - remote, - localMap: new Set(local.map(f => f.path)), - allMap: new Map(allFiles.map(f => [f.path, f])) + remoteMap, + localMap: new Set([...local.map(f => f.path), ...filteredHiddenPaths]), + allMap: new Map(allFiles.map(f => [f.path, f])), + hiddenLocalPaths: filteredHiddenPaths }; } + private getNormalizedRemotePath(remotePath: string): string | null { + const rootPath = this.plugin.settings.rootPath; + if (!rootPath) return remotePath; + + const cleanRoot = rootPath.endsWith('/') ? rootPath : `${rootPath}/`; + if (remotePath.startsWith(cleanRoot)) { + return remotePath.substring(cleanRoot.length); + } + if (remotePath === rootPath) return ''; + return null; + } + + private async discoverHiddenLocalFiles(): Promise { + const result: string[] = []; + const vaultFolder = this.plugin.settings.vaultFolder || ''; + await this.recursiveScan(vaultFolder, result); + return result; + } + + private async recursiveScan(folderPath: string, result: string[]): Promise { + try { + const listing = await this.app.vault.adapter.list(folderPath); + for (const file of listing.files) { + if (this.isHidden(file)) { + result.push(file); + } + } + for (const folder of listing.folders) { + if (folder === '.git' || folder.endsWith('/.git')) continue; + await this.recursiveScan(folder, result); + } + } catch { /* adapter may not support listing */ } + } + + private isHidden(path: string): boolean { + return path.split('/').some(part => part.startsWith('.')); + } + private initializeFileStatuses(localFiles: TFile[]): void { for (const file of localFiles) { this.fileStatuses.set(file.path, { file, path: file.path, status: 'checking' }); } } - private async identifyExtraFiles(remoteFiles: string[], localFilePaths: Set, allLocalFileMap: Map) { + private async identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map) { const extra: Array = []; - for (const remotePath of remoteFiles) { - if (localFilePaths.has(remotePath)) continue; + for (const [vaultPath] of remoteMap.entries()) { + if (localFilePaths.has(vaultPath)) continue; - let localFile = allLocalFileMap.get(remotePath); + let localFile = allLocalFileMap.get(vaultPath); if (!localFile) { - const abs = this.app.vault.getAbstractFileByPath(remotePath); + const abs = this.app.vault.getAbstractFileByPath(vaultPath); if (abs instanceof TFile) localFile = abs; } if (localFile) { extra.push(localFile); - } else if (await this.app.vault.adapter.exists(remotePath)) { - extra.push(remotePath); + } else if (await this.app.vault.adapter.exists(vaultPath)) { + extra.push(vaultPath); } else { - this.fileStatuses.set(remotePath, { path: remotePath, status: 'remote-only' }); + this.fileStatuses.set(vaultPath, { path: vaultPath, status: 'remote-only' }); } } return extra; @@ -622,34 +405,24 @@ export class SyncStatusView extends ItemView { } } - private getCheckableFiles(local: TFile[], extra: Array) { - const combined: Array = [...local, ...extra]; - return combined.filter(f => { + private getCheckableFiles(local: TFile[], extra: Array, hiddenLocalPaths: Set = new Set()): Array { + const extraPaths = new Set(extra.map(f => typeof f === 'string' ? f : f.path)); + // Hidden local files already in localMap won't appear in extra; add them directly + const hiddenToAdd = [...hiddenLocalPaths].filter(p => !extraPaths.has(p)); + return ([...local, ...extra, ...hiddenToAdd] as Array).filter(f => { const p = typeof f === 'string' ? f : f.path; - return !this.plugin.gitignoreManager.isIgnored(p); + return !this.plugin.gitignoreManager.isIgnored(this.plugin.getNormalizedPath(p)); }); } private async performStatusCheck(filesToCheck: Array): Promise { const total = filesToCheck.length; + this.refreshProgress = { current: 0, total }; for (let i = 0; i < total; i++) { const file = filesToCheck[i]; - if (file) { - await this.refreshFileStatus(file); - } - this.updateRefreshProgress(i + 1, total); - } - } - - private updateRefreshProgress(current: number, total: number): void { - const c = this.containerEl.children[1]; - if (!c) return; - const fill = c.querySelector('.ssv-progress-fill'); - const text = c.querySelector('.ssv-progress-text'); - if (fill && text) { - const pct = Math.round((current / total) * 100); - fill.setAttr('style', `width: ${pct}%`); - text.textContent = `Checking files… ${current}/${total} (${pct}%)`; + if (file) await this.refreshFileStatus(file); + this.refreshProgress.current = i + 1; + this.renderView(); } } @@ -659,23 +432,14 @@ export class SyncStatusView extends ItemView { const path = isStr ? fileOrPath : fileOrPath.path; const file = isStr ? undefined : fileOrPath; - const localContent = isStr - ? await this.app.vault.adapter.read(fileOrPath) - : await this.app.vault.read(fileOrPath); + const binary = this.isBinary(path); + const localContent = await this.readFileContent(fileOrPath, binary, isStr); - const remote = await this.plugin.gitService.getFile(path, this.plugin.settings.branch); + // Important: Use SyncManager's logic which handles rootPath/vaultFolder mapping + const repoPath = this.plugin.getNormalizedPath(path); + const remote = await this.plugin.gitService.getFile(repoPath, this.plugin.settings.branch); - let status: FileStatus['status']; - let diff: string | undefined; - - if (!remote.sha) { - status = 'unsynced'; - } else if (localContent === remote.content) { - status = 'synced'; - } else { - status = 'modified'; - diff = this.generateDiff(remote.content, localContent); - } + const { status, diff } = this.determineFileStatus(binary, localContent, remote); this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff }); } catch { @@ -688,6 +452,45 @@ export class SyncStatusView extends ItemView { } } + private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStr: boolean): Promise { + if (isStr) { + return binary + ? await this.app.vault.adapter.readBinary(fileOrPath as string) + : await this.app.vault.adapter.read(fileOrPath as string); + } + if (fileOrPath instanceof TFile) { + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); + } + // This should not happen if isStr is false and fileOrPath is TFile + throw new Error('Expected TFile when isStr is false'); + } + + private determineFileStatus(binary: boolean, localContent: string | ArrayBuffer, remote: { sha?: string; content?: string | ArrayBuffer }): { status: FileStatus['status']; diff?: string } { + if (!remote.sha) { + return { status: 'unsynced' }; + } + if (remote.content && this.contentsEqual(localContent, remote.content)) { + return { status: 'synced' }; + } + const diff = this.computeDiff(binary, localContent, remote.content || ''); + return { status: 'modified', diff }; + } + + private computeDiff(binary: boolean, localContent: string | ArrayBuffer, remoteContent: string | ArrayBuffer): string { + if (binary || typeof localContent !== 'string' || typeof remoteContent !== 'string') { + return 'Binary file changed'; + } + return this.generateDiff(remoteContent, localContent); + } + + private isBinary(path: string): boolean { return isBinaryPath(path); } + + private contentsEqual(a: string | ArrayBuffer, b: string | ArrayBuffer): boolean { + return contentsEqual(a, b); + } + private generateDiff(oldContent: string, newContent: string): string { const oldLines = oldContent.split('\n'); const newLines = newContent.split('\n'); @@ -703,21 +506,20 @@ export class SyncStatusView extends ItemView { return diff.join('\n'); } - async pushAllModified(): Promise { - await this.runBatchOperation('modified', 'push'); - } + // ── Batch push/pull/delete ───────────────────────────────────── - async pullAllModified(): Promise { - await this.runBatchOperation('modified', 'pull'); - } + async pushAllModified(): Promise { await this.runBatchOperation('modified', 'push'); } + async pullAllModified(): Promise { await this.runBatchOperation('modified', 'pull'); } + async pushSelected(): Promise { await this.runBatchOperation('selected', 'push'); } + async pullSelected(): Promise { await this.runBatchOperation('selected', 'pull'); } private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise { - const targets = Array.from(this.fileStatuses.values()) - .filter(s => { - if (filter === 'selected' && !this.selectedFiles.has(s.path)) return false; - if (op === 'push') return s.status === 'modified' || s.status === 'unsynced'; - return s.status === 'modified' || s.status === 'remote-only'; - }); + const targets = Array.from(this.fileStatuses.values()).filter(s => { + if (filter === 'selected' && !this.selectedFiles.has(s.path)) return false; + return op === 'push' + ? s.status === 'modified' || s.status === 'unsynced' + : s.status === 'modified' || s.status === 'remote-only'; + }); if (targets.length === 0) { new Notice(`No ${op}able files ${filter === 'selected' ? 'selected' : 'found'}.`); @@ -726,7 +528,7 @@ export class SyncStatusView extends ItemView { const files = targets.map(s => s.file || s.path); const serviceName = getServiceName(this.plugin.settings); - const msg = op === 'push' + const msg = op === 'push' ? `Push ${files.length} file(s) to ${serviceName}?` : `Pull ${files.length} file(s) from ${serviceName}? This will overwrite local changes.`; @@ -739,9 +541,8 @@ export class SyncStatusView extends ItemView { : await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(`Pulling ${cur}/${total}: ${name}`)); prog.hide(); - if (results.errors.length > 0) console.error(`${op} errors:`, results.errors); + if (results.errors.length > 0) logger.error(`${op} errors:`, results.errors); if (filter === 'selected') this.selectedFiles.clear(); - new Notice(`${op === 'push' ? 'Push' : 'Pull'} completed. Refreshing…`); await this.refreshAllStatuses(); } catch (e) { @@ -750,21 +551,12 @@ export class SyncStatusView extends ItemView { } } - async pushSelected(): Promise { - await this.runBatchOperation('selected', 'push'); - } - - async pullSelected(): Promise { - await this.runBatchOperation('selected', 'pull'); - } - async deleteSelected(): Promise { const targets = this.getSelectedTargets(); if (targets.length === 0) return; const { local, remote } = this.partitionTargets(targets); if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; } - if (!await this.confirmDeletion(local.length, remote.length)) return; const total = local.length + remote.length; @@ -775,7 +567,10 @@ export class SyncStatusView extends ItemView { await this.performRemoteDeletion(remote, total, local.length, prog, errors); prog.hide(); - this.notifyDeletionResults(total, errors.length); + new Notice(errors.length > 0 + ? `Deleted ${total - errors.length}/${total}. ${errors.length} failed.` + : `Deleted ${total} files` + ); this.renderView(); } @@ -788,7 +583,7 @@ export class SyncStatusView extends ItemView { private partitionTargets(targets: FileStatus[]) { return { - local: targets.filter(s => s.status !== 'remote-only'), + local: targets.filter(s => s.status !== 'remote-only'), remote: targets.filter(s => s.status === 'remote-only') }; } @@ -798,8 +593,7 @@ export class SyncStatusView extends ItemView { if (localCount > 0 && remoteCount > 0) msg = `Delete ${localCount} local + ${remoteCount} remote file(s)? Cannot be undone.`; else if (localCount > 0) msg = `Delete ${localCount} local file(s)? Cannot be undone.`; else msg = `Delete ${remoteCount} remote file(s)? Cannot be undone.`; - - return await this.showConfirmDialog(msg); + return this.showConfirmDialog(msg); } private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: string[]): Promise { @@ -829,25 +623,11 @@ export class SyncStatusView extends ItemView { } } - private notifyDeletionResults(total: number, errorCount: number): void { - new Notice(errorCount > 0 - ? `Deleted ${total - errorCount}/${total}. ${errorCount} failed.` - : `Deleted ${total} files` - ); - } - - onClose(): Promise { - return Promise.resolve(); - } + onClose(): Promise { return Promise.resolve(); } private showConfirmDialog(message: string): Promise { return new Promise(resolve => { - new ConfirmModal( - this.app, - message, - () => resolve(true), - () => resolve(false) - ).open(); + new ConfirmModal(this.app, message, () => resolve(true), () => resolve(false)).open(); }); } } diff --git a/src/ui/components/ActionBar.ts b/src/ui/components/ActionBar.ts new file mode 100644 index 0000000..486e19d --- /dev/null +++ b/src/ui/components/ActionBar.ts @@ -0,0 +1,57 @@ +import { setTooltip } from 'obsidian'; + +export interface ActionBarProps { + hasFiles: boolean; + allSelected: boolean; + indeterminate: boolean; + canPush: number; + canPull: number; + canDelete: number; +} + +export interface ActionBarCallbacks { + onRefresh: () => void; + onSelectAll: (select: boolean) => void; + onPush: () => void; + onPull: () => void; + onDelete: () => void; +} + +export function renderActionBar(container: HTMLElement, props: ActionBarProps, callbacks: ActionBarCallbacks): void { + const bar = container.createDiv({ cls: 'ssv-action-bar' }); + renderRefreshButton(bar, callbacks.onRefresh); + + if (props.hasFiles) { + bar.createDiv({ cls: 'ssv-bar-spacer' }); + renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll); + renderLargeButton(bar, '↑', ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0); + renderLargeButton(bar, '↓', ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0); + renderLargeButton(bar, '✕', ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0); + } +} + +function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void { + const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); + btn.createSpan({ text: '↻' }); + btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' }); + setTooltip(btn, 'Refresh all statuses'); + btn.addEventListener('click', onRefresh); +} + +function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminate: boolean, onSelectAll: (select: boolean) => void): void { + const selectRow = bar.createDiv({ cls: 'ssv-select-row' }); + const cb = selectRow.createEl('input', { type: 'checkbox' }); + cb.checked = allSelected; + cb.indeterminate = indeterminate; + selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' }); + cb.addEventListener('change', () => onSelectAll(cb.checked)); +} + +function 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); +} diff --git a/src/ui/components/DiffPanel.ts b/src/ui/components/DiffPanel.ts new file mode 100644 index 0000000..816a836 --- /dev/null +++ b/src/ui/components/DiffPanel.ts @@ -0,0 +1,31 @@ +import { computeSideBySideDiff, type DiffSide } from '../../utils/diff'; + +export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, localContent: string): HTMLElement { + const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); + const rows = computeSideBySideDiff(remoteContent, localContent); + + const grid = diffEl.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' }); + grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' }); + grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' }); + for (const row of rows) { + renderDiffCell(grid, row.left); + renderDiffCell(grid, row.right); + } + + const unifiedEl = diffEl.createEl('pre', { cls: 'ssv-diff-unified' }); + for (const { left, right } of rows) { + if (left.type === 'removed') unifiedEl.createSpan({ cls: 'ssv-u-line removed' }).textContent = `- ${left.content ?? ''}\n`; + if (right.type === 'added') unifiedEl.createSpan({ cls: 'ssv-u-line added' }).textContent = `+ ${right.content ?? ''}\n`; + if (left.type === 'unchanged') unifiedEl.createSpan({ cls: 'ssv-u-line unchanged' }).textContent = ` ${left.content ?? ''}\n`; + } + + return diffEl; +} + +function renderDiffCell(grid: HTMLElement, side: DiffSide): void { + const cell = grid.createDiv({ cls: `ssv-diff-cell ${side.type}` }); + cell.createSpan({ cls: 'ssv-diff-ln' }).textContent = side.lineNum === null ? '' : String(side.lineNum); + if (side.content !== null) { + cell.createSpan({ cls: 'ssv-diff-code' }).textContent = side.content; + } +} diff --git a/src/ui/components/FileListItem.ts b/src/ui/components/FileListItem.ts new file mode 100644 index 0000000..55d4f60 --- /dev/null +++ b/src/ui/components/FileListItem.ts @@ -0,0 +1,96 @@ +import { setTooltip } from 'obsidian'; +import { type FileStatus } from '../types'; +import { renderDiffPanel } from './DiffPanel'; + +export interface FileItemCallbacks { + onSelect: (path: string, selected: boolean) => void; + onPush: (fileStatus: FileStatus) => void; + onPull: (fileStatus: FileStatus) => void; + onDelete: (fileStatus: FileStatus) => void; +} + +export function statusMeta(status: FileStatus['status']) { + switch (status) { + case 'synced': return { icon: '✓', label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; + case 'modified': return { icon: '⚠', label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; + case 'unsynced': return { icon: '↑', label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; + case 'remote-only': return { icon: '↓', label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; + default: return { icon: '⟳', label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; + } +} + +export function renderFileItem( + container: HTMLElement, + fileStatus: FileStatus, + isSelected: boolean, + callbacks: FileItemCallbacks +): void { + const { icon, label, iconCls, badgeCls, fileCls } = statusMeta(fileStatus.status); + const fileEl = container.createDiv({ cls: `ssv-file ${fileCls}` }); + const row = fileEl.createDiv({ cls: 'ssv-file-row' }); + + const cb = row.createEl('input', { type: 'checkbox', cls: 'ssv-file-checkbox' }); + cb.checked = isSelected; + cb.addEventListener('change', () => callbacks.onSelect(fileStatus.path, cb.checked)); + + row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon }); + row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path }); + row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label }); + + if (fileStatus.status !== 'synced' && fileStatus.status !== 'checking') { + renderFileActions(fileEl, fileStatus, callbacks); + } +} + +function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void { + const actions = fileEl.createDiv({ cls: 'ssv-file-actions' }); + + if (fileStatus.status === 'modified' && fileStatus.diff) { + renderDiffToggleButton(actions, fileEl, fileStatus); + } + + if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') { + renderActionBtn(actions, '↑', ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push'); + } + + if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') { + renderActionBtn(actions, '↓', ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull'); + } + + if (fileStatus.status === 'unsynced') { + renderActionBtn(actions, '✕', ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger'); + } +} + +function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void { + const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); + diffBtn.createSpan({ text: '≡' }); + const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); + + let diffEl: HTMLElement; + if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') { + diffEl = renderDiffPanel(fileEl, fileStatus.remoteContent, fileStatus.localContent); + } else { + diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); + diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' }); + } + + setTooltip(diffBtn, 'Toggle diff view'); + diffBtn.addEventListener('click', () => { + const open = diffEl.hasClass('visible'); + diffEl.toggleClass('visible', !open); + btnLabel.setText(open ? ' Diff' : ' Hide'); + const firstChild = diffBtn.firstChild; + if (firstChild instanceof HTMLElement || firstChild instanceof Text) { + firstChild.textContent = open ? '≡' : '▴'; + } + }); +} + +function renderActionBtn(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void { + const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` }); + btn.createSpan({ text: icon }); + btn.createSpan({ cls: 'ssv-btn-label', text: label }); + setTooltip(btn, tooltip); + btn.addEventListener('click', onClick); +} diff --git a/src/ui/types.ts b/src/ui/types.ts new file mode 100644 index 0000000..7c1446f --- /dev/null +++ b/src/ui/types.ts @@ -0,0 +1,13 @@ +import { TFile } from 'obsidian'; + +export interface FileStatus { + file?: TFile; + path: string; + status: 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'checking'; + localContent?: string | ArrayBuffer; + remoteContent?: string | ArrayBuffer; + remoteSha?: string; + diff?: string; +} + +export type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..422b1fc --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,154 @@ +export interface DiffSide { + lineNum: number | null; + content: string | null; + type: 'removed' | 'added' | 'unchanged' | 'empty'; +} + +export interface DiffRow { + left: DiffSide; + right: DiffSide; +} + +type DiffOpType = 'unchanged' | 'removed' | 'added'; + +interface DiffOp { + type: DiffOpType; + li: number; + ri: number; +} + +export function computeSideBySideDiff(remote: string, local: string): DiffRow[] { + const L = normalizeContent(remote).split('\n'); + const R = normalizeContent(local).split('\n'); + const m = L.length, n = R.length; + + if (m * n > 250_000 || (m + 1) * (n + 1) > 1_000_000) { + return simpleDiff(L, R); + } + + const dp = buildDPMatrix(L, R, m, n); + const ops = tracePath(L, R, dp, m, n); + return pairDiffOps(ops, L, R); +} + +function normalizeContent(s: string): string { + return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function buildDPMatrix(L: string[], R: string[], m: number, n: number): Uint32Array { + const W = n + 1; + 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)]!) + 1 + : Math.max(dp[(i - 1) * W + j]!, dp[i * W + (j - 1)]!); + } + } + return dp; +} + +function 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) { + const op = getNextDiffOp(L, R, dp, W, i, j); + ops.push(op); + [i, j] = updateIndices(op, i, j); + } + return ops.reverse(); +} + +function 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]; +} + +function 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 }; +} + +function pairDiffOps(ops: DiffOp[], L: string[], R: string[]): DiffRow[] { + const rows: DiffRow[] = []; + let k = 0; + while (k < ops.length) { + const op = ops[k]; + if (!op) break; + + if (op.type === 'unchanged') { + rows.push(createUnchangedRow(op, L, R)); + k++; + } else { + const batch = collectChangeBatch(ops, k); + rows.push(...createChangeRows(batch, L, R)); + k += batch.length; + } + } + return rows; +} + +function 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' }, + }; +} + +function 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; +} + +function 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: createDiffSide(removedIdxs[x], L, 'removed'), + right: createDiffSide(addedIdxs[x], R, 'added') + }); + } + return rows; +} + +function 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 }; +} + +function simpleDiff(L: string[], R: string[]): DiffRow[] { + const rows: DiffRow[] = []; + const max = Math.max(L.length, R.length); + for (let i = 0; i < max; i++) { + const l = L[i], r = R[i]; + if (l === undefined) rows.push({ left: { lineNum: null, content: null, type: 'empty' }, right: { lineNum: i + 1, content: r ?? null, type: 'added' } }); + else if (r === undefined) rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: null, content: null, type: 'empty' } }); + else if (l === r) rows.push({ left: { lineNum: i + 1, content: l, type: 'unchanged' }, right: { lineNum: i + 1, content: r, type: 'unchanged' } }); + else rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: i + 1, content: r, type: 'added' } }); + } + return rows; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..2ccd5eb --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,6 @@ +const PREFIX = '[git-file-sync]'; + +export const logger = { + error: (message: string, ...args: unknown[]) => console.error(`${PREFIX} ${message}`, ...args), + warn: (message: string, ...args: unknown[]) => console.warn(`${PREFIX} ${message}`, ...args), +}; diff --git a/src/utils/path.ts b/src/utils/path.ts new file mode 100644 index 0000000..2b78dc7 --- /dev/null +++ b/src/utils/path.ts @@ -0,0 +1,26 @@ +export const BINARY_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', '7z', 'rar', + 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi', 'wmv', 'webp', + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'epub', 'exe', 'dll', 'so', + 'ttf', 'woff', 'woff2', 'eot', 'wasm', 'dmg', 'iso' +]); + +export function isBinaryPath(path: string): boolean { + const ext = path.split('.').pop()?.toLowerCase(); + if (!ext) return false; + return BINARY_EXTENSIONS.has(ext); +} + +export function contentsEqual(a: string | ArrayBuffer, b: string | ArrayBuffer): boolean { + if (typeof a === 'string' && typeof b === 'string') return a === b; + if (typeof a !== typeof b) return false; + const bufA = a as ArrayBuffer; + const bufB = b as ArrayBuffer; + if (bufA.byteLength !== bufB.byteLength) return false; + const viewA = new Uint8Array(bufA); + const viewB = new Uint8Array(bufB); + for (let i = 0; i < viewA.length; i++) { + if (viewA[i] !== viewB[i]) return false; + } + return true; +} diff --git a/styles.css b/styles.css index 6c28f70..73075c9 100644 --- a/styles.css +++ b/styles.css @@ -10,7 +10,6 @@ container-type: inline-size; } -.hidden { display: none !important; } /* ── Info strip ─────────────────────────────────────────────────── */ .ssv-info { @@ -141,10 +140,6 @@ } /* ── Mobile specific overrides ─────────────────────────────────── */ -.is-mobile .ssv-btn-label, -.is-mobile .ssv-tab-label { - display: inline !important; -} .is-mobile .ssv-btn { padding: 8px 14px; @@ -369,9 +364,6 @@ min-height: 40px; } -.is-mobile .ssv-action-btn .ssv-btn-label { - display: inline !important; -} .ssv-action-btn:hover { @@ -542,6 +534,16 @@ .ssv-diff { padding-left: 0; } } +/* Mobile label overrides — placed after container queries so source order wins */ +.is-mobile .ssv-btn-label, +.is-mobile .ssv-tab-label { + display: inline; +} + +.is-mobile .ssv-action-btn .ssv-btn-label { + display: inline; +} + /* ── Conflict Modal ─────────────────────────────────────────────── */ .sync-conflict-modal { max-width: 900px; } @@ -564,7 +566,7 @@ .conflict-section h3, .conflict-diff-section h3 { - margin: 0 0 8px 0; + margin: 0 0 8px; font-size: 0.9em; font-weight: 600; } @@ -597,7 +599,7 @@ @media (max-width: 480px) { .conflict-buttons { justify-content: stretch; } .conflict-buttons .setting-item-control { flex: 1; display: flex; flex-direction: column; gap: 8px; } - .conflict-buttons button { width: 100%; margin: 0 !important; } + .sync-conflict-modal .conflict-buttons button { width: 100%; margin: 0; } } .conflict-buttons .setting-item { diff --git a/tests/logic/gitignore-manager.test.ts b/tests/logic/gitignore-manager.test.ts index 8e320f5..c75158e 100644 --- a/tests/logic/gitignore-manager.test.ts +++ b/tests/logic/gitignore-manager.test.ts @@ -18,6 +18,7 @@ describe('GitignoreManager', () => { const mockAdapter = { exists: vi.fn(), read: vi.fn(), + list: vi.fn().mockResolvedValue({ files: [], folders: [] }), } as unknown as Mocked; mockApp = { @@ -95,7 +96,7 @@ describe('GitignoreManager', () => { // .gitignore // sub/.gitignore vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore', 'sub/.gitignore']); - + const adapter = mockApp.vault.adapter as Mocked; vi.mocked(adapter.exists).mockResolvedValue(true); vi.mocked(adapter.read).mockImplementation((path) => { @@ -115,6 +116,34 @@ describe('GitignoreManager', () => { // Should ignore sub/root-ignored.txt (root .gitignore applies to subfolders too) expect(manager.isIgnored('sub/root-ignored.txt')).toBe(true); }); + + it('should pick up local-only subdirectory .gitignore not yet on remote', async () => { + // Remote only knows about root .gitignore; sub/.gitignore exists locally but not pushed yet + vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']); + + const adapter = mockApp.vault.adapter as Mocked; + vi.mocked(adapter.list).mockImplementation((dir: string) => { + if (dir === '' || dir === undefined) { + return Promise.resolve({ files: ['.gitignore'], folders: ['sub'] }); + } + if (dir === 'sub') { + return Promise.resolve({ files: ['sub/.gitignore'], folders: [] }); + } + return Promise.resolve({ files: [], folders: [] }); + }); + vi.mocked(adapter.exists).mockResolvedValue(true); + vi.mocked(adapter.read).mockImplementation((path: string) => { + if (path === '.gitignore') return Promise.resolve('root-only.log'); + if (path === 'sub/.gitignore') return Promise.resolve('local-only.tmp'); + return Promise.resolve(''); + }); + + await manager.loadGitignores(); + + expect(manager.isIgnored('sub/local-only.tmp')).toBe(true); + expect(manager.isIgnored('local-only.tmp')).toBe(false); + expect(manager.isIgnored('root-only.log')).toBe(true); + }); }); describe('isIgnored with rootPath (vault is subdirectory)', () => { diff --git a/tests/logic/sync-manager-batch.test.ts b/tests/logic/sync-manager-batch.test.ts index 39a50ec..8bbec82 100644 --- a/tests/logic/sync-manager-batch.test.ts +++ b/tests/logic/sync-manager-batch.test.ts @@ -70,8 +70,8 @@ describe('SyncManager Batch Operations', () => { vi.mocked(mockApp.vault.read).mockResolvedValue('content2'); vi.mocked(mockApp.vault.getFileByPath).mockReturnValue(mockFile); - vi.mocked(mockGitService.getFile).mockResolvedValue({ content: '', sha: 'old-sha' }); - vi.mocked(mockGitService.pushFile).mockResolvedValue('path'); + vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'diff', sha: 'old-sha' }); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: 'path', sha: 'new-sha' }); const results = await manager.pushAllFiles(files); @@ -86,10 +86,10 @@ describe('SyncManager Batch Operations', () => { vi.mocked(adapter.exists).mockResolvedValue(true); vi.mocked(adapter.read).mockResolvedValue('content'); - vi.mocked(mockGitService.getFile).mockResolvedValue({ content: '', sha: 'old-sha' }); + vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'diff', sha: 'old-sha' }); vi.mocked(mockGitService.pushFile) - .mockResolvedValueOnce('path') + .mockResolvedValueOnce({ path: 'path', sha: 'new-sha' }) .mockRejectedValueOnce(new Error('Push failed')); const results = await manager.pushAllFiles(files); @@ -138,8 +138,8 @@ describe('SyncManager Batch Operations', () => { vi.mocked(adapter.exists).mockResolvedValue(true); vi.mocked(adapter.read).mockResolvedValue('content'); - vi.mocked(mockGitService.getFile).mockResolvedValue({ content: '', sha: 'sha' }); - vi.mocked(mockGitService.pushFile).mockResolvedValue('path'); + vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'diff', sha: 'sha' }); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: 'path', sha: 'new' }); const onProgress = vi.fn(); await manager.pushAllFiles(files, onProgress); @@ -163,7 +163,7 @@ describe('SyncManager Batch Operations', () => { vi.mocked(mockApp.vault.getFileByPath).mockImplementation(p => p === oldPath ? null : mockFile); vi.mocked(mockApp.vault.read).mockResolvedValue('content'); vi.mocked(mockApp.vault.adapter.exists as ReturnType).mockResolvedValue(true); - vi.mocked(mockGitService.pushFile).mockResolvedValue(newPath); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: newPath, sha: 'new-sha' }); vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'content', sha: 'new-sha' }); const results = await manager.pushAllFiles([mockFile]); diff --git a/tests/logic/sync-manager-mapping.test.ts b/tests/logic/sync-manager-mapping.test.ts new file mode 100644 index 0000000..f69faa5 --- /dev/null +++ b/tests/logic/sync-manager-mapping.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SyncManager } from '../../src/logic/sync-manager'; + +import { App, TFile } from 'obsidian'; +import type { GitServiceInterface } from '../../src/services/git-service-interface'; +import type { GitLabFilesPushSettings } from '../../src/settings'; + +vi.mock('obsidian', () => ({ + Notice: vi.fn(), + TFile: class { + path: string = ''; + name: string = ''; + }, + App: class {}, +})); + +const mockApp = { + vault: { + read: vi.fn(), + modify: vi.fn(), + getFileByPath: vi.fn(), + getAbstractFileByPath: vi.fn(), + createFolder: vi.fn(), + adapter: { + exists: vi.fn(), + read: vi.fn(), + write: vi.fn(), + } + } +} as unknown as App; + +const mockGetFile = vi.fn(); +const mockPushFile = vi.fn(); +const mockGitService = { + getFile: mockGetFile, + pushFile: mockPushFile, +} as unknown as GitServiceInterface; + +const mockSettings: GitLabFilesPushSettings = { + serviceType: 'gitlab', + gitlabToken: '', + gitlabBaseUrl: 'https://gitlab.com', + projectId: '', + githubToken: '', + githubOwner: '', + githubRepo: '', + branch: 'main', + rootPath: 'notes', + vaultFolder: 'Work', + syncMetadata: {}, +}; + +describe('SyncManager Mapping', () => { + let manager: SyncManager; + + beforeEach(() => { + vi.clearAllMocks(); + mockSettings.syncMetadata = {}; + manager = new SyncManager(mockApp, mockGitService, mockSettings); + }); + + it('should strip vaultFolder when pushing', async () => { + const vaultPath = 'Work/test.md'; + const mockFile = Object.assign(new TFile(), { path: vaultPath, name: 'test.md' }); + + const getFileByPathSpy = vi.spyOn(mockApp.vault, 'getFileByPath'); + const readSpy = vi.spyOn(mockApp.vault, 'read'); + getFileByPathSpy.mockReturnValue(mockFile); + readSpy.mockResolvedValue('content'); + + vi.mocked(mockGetFile).mockResolvedValue({ content: '', sha: '' }); + vi.mocked(mockPushFile).mockResolvedValue({ path: 'notes/test.md' }); + + await manager.pushFile(mockFile); + + expect(mockGetFile).toHaveBeenCalledWith('test.md', 'main'); + expect(mockPushFile).toHaveBeenCalledWith( + 'test.md', + 'content', + 'main', + 'Update test.md from Obsidian', + '' + ); + }); + + it('should map back to vaultFolder when pulling', async () => { + const vaultPath = 'Work/remote.md'; + vi.mocked(mockGetFile).mockResolvedValue({ content: 'remote content', sha: 'sha' }); + const existsSpy = vi.spyOn(mockApp.vault.adapter, 'exists'); + const writeSpy = vi.spyOn(mockApp.vault.adapter, 'write'); + existsSpy.mockResolvedValue(false); + writeSpy.mockResolvedValue(undefined); + + await manager.pullFile(vaultPath); + + expect(mockGetFile).toHaveBeenCalledWith('remote.md', 'main'); + expect(writeSpy).toHaveBeenCalledWith(vaultPath, 'remote content'); + }); + + it('should handle root-level files correctly when no vaultFolder', async () => { + mockSettings.vaultFolder = ''; + manager = new SyncManager(mockApp, mockGitService, mockSettings); + + const path = 'root.md'; + const mockFile = Object.assign(new TFile(), { path, name: 'root.md' }); + + const getFileByPathSpy = vi.spyOn(mockApp.vault, 'getFileByPath'); + const readSpy = vi.spyOn(mockApp.vault, 'read'); + getFileByPathSpy.mockReturnValue(mockFile); + readSpy.mockResolvedValue('content'); + + vi.mocked(mockGetFile).mockResolvedValue({ content: '', sha: '' }); + + await manager.pushFile(mockFile); + + expect(mockGetFile).toHaveBeenCalledWith('root.md', 'main'); + }); +}); diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 4d897a9..57cb7ca 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -71,8 +71,9 @@ describe('SyncManager', () => { it('should push file content correctly', async () => { const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); const readSpy = vi.spyOn(mockApp.vault, 'read').mockResolvedValue('local content'); - const getSpy = vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: '', sha: '' }); - const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue('test.md'); + // Mock getFile to return different content to trigger a push + const getSpy = vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'different content', sha: 'old-sha' }); + const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'test.md', sha: 'new-sha' }); await manager.pushFile(mockFile); @@ -83,7 +84,7 @@ describe('SyncManager', () => { 'local content', 'main', 'Update test.md from Obsidian', - '' + 'old-sha' ); }); @@ -97,7 +98,7 @@ describe('SyncManager', () => { }; vi.spyOn(mockApp.vault, 'read').mockResolvedValue('local content'); - // Mock GitLab returning a different remote SHA + // Mock GitLab returning a different remote SHA and different content vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'remote content', sha: 'new-remote-sha' }); const modalMock = vi.mocked(SyncConflictModal); @@ -113,8 +114,8 @@ describe('SyncManager', () => { vi.spyOn(mockApp.vault, 'read').mockResolvedValue('local content'); vi.spyOn(mockGitLab, 'getFile').mockResolvedValueOnce({ content: 'remote content', sha: 'remote-sha' }); - vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue('test.md'); - vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'local content', sha: 'new-sha' }); + vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'test.md', sha: 'new-sha' }); + // No second getFile call needed if pushFile returns sha const modalMock = vi.mocked(SyncConflictModal); @@ -134,7 +135,7 @@ describe('SyncManager', () => { callback('local'); // Wait for async operations in callback - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setTimeout(resolve, 50)); const pushSpy = vi.spyOn(mockGitLab, 'pushFile'); expect(pushSpy).toHaveBeenCalledWith('test.md', 'local content', 'main', 'Update test.md from Obsidian', 'remote-sha'); @@ -177,12 +178,9 @@ describe('SyncManager', () => { mockSettings.syncMetadata = {}; vi.spyOn(mockApp.vault, 'read').mockResolvedValue('local content'); - // First call for conflict detection - vi.spyOn(mockGitLab, 'getFile').mockResolvedValueOnce({ content: '', sha: '' }); - vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue('test.md'); - - // Second call for metadata update - vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'local content', sha: 'new-sha' }); + // Mock getFile to return different content to trigger push + vi.spyOn(mockGitLab, 'getFile').mockResolvedValueOnce({ content: 'diff', sha: 'old' }); + vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'test.md', sha: 'new-sha' }); await manager.pushFile(mockFile); @@ -224,8 +222,7 @@ describe('SyncManager', () => { vi.spyOn(mockApp.vault, 'read').mockResolvedValue('new local content'); // Remote returns 404/empty vi.spyOn(mockGitLab, 'getFile').mockResolvedValueOnce({ content: '', sha: '' }); - vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue('new.md'); - vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'new local content', sha: 'new-sha' }); + vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'new.md', sha: 'new-sha' }); await manager.pushFile(mockFile); @@ -261,8 +258,7 @@ describe('SyncManager', () => { }); vi.spyOn(mockApp.vault, 'read').mockResolvedValue('content'); - vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue(newPath); - vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'content', sha: 'new-sha' }); + vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: newPath, sha: 'new-sha' }); await manager.pushFile(mockFile); diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index fcc1e5e..1bad1c1 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GitHubService } from '../../src/services/github-service'; import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian'; +import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers'; describe('GitHubService', () => { let service: GitHubService; @@ -16,34 +17,39 @@ describe('GitHubService', () => { describe('getFile', () => { it('should fetch and decode file content correctly', async () => { - const mockResponse = { - status: 200, - json: { - content: btoa('hello world'), - sha: 'test-sha' - } - }; - vi.mocked(requestUrl).mockResolvedValue(mockResponse as unknown as RequestUrlResponse); - + mockRequest({ status: 200, json: { content: btoa('hello world'), sha: 'test-sha' } }); const result = await service.getFile('test.md', 'main'); - expect(result.content).toBe('hello world'); expect(result.sha).toBe('test-sha'); }); it('should handle 404 correctly and return empty content', async () => { - vi.mocked(requestUrl).mockResolvedValue({ status: 404 } as unknown as RequestUrlResponse); + mockRequest({ status: 404 }); const result = await service.getFile('missing.md', 'main'); expect(result.content).toBe(''); expect(result.sha).toBe(''); }); + it('should bypass rootPath when path starts with / (absolute repo path)', async () => { + service.updateConfig(token, owner, repo, 'vault'); + mockRequest({ status: 200, json: { content: btoa('root content'), sha: 'root-sha' } }); + await service.getFile('/.gitignore', 'main'); + const call = getLastRequestCall(); + expect(call.url).toContain('/contents/.gitignore'); + expect(call.url).not.toContain('/contents/vault/.gitignore'); + }); + + it('should not double-prefix when path already starts with rootPath', async () => { + service.updateConfig(token, owner, repo, 'src/content'); + mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } }); + await service.getFile('src/content/index.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).toContain('/contents/src/content/index.md'); + expect(call.url).not.toContain('/contents/src/content/src/content/index.md'); + }); + it('should return sha correctly', async () => { - const mockResponse = { - status: 200, - json: { content: btoa('test'), sha: 'explicit-sha' } - }; - vi.mocked(requestUrl).mockResolvedValue(mockResponse as unknown as RequestUrlResponse); + mockRequest({ status: 200, json: { content: btoa('test'), sha: 'explicit-sha' } }); const result = await service.getFile('test.md', 'main'); expect(result.sha).toBe('explicit-sha'); }); @@ -51,86 +57,66 @@ describe('GitHubService', () => { describe('pushFile', () => { it('should push new file correctly (no sha provided)', async () => { - vi.mocked(requestUrl) - .mockResolvedValueOnce({ - status: 201, - json: { content: { path: 'new.md' } } - } as unknown as RequestUrlResponse); + vi.mocked(requestUrl).mockResolvedValueOnce({ + status: 201, + json: { content: { path: 'new.md', sha: 'new-sha' } } + } as unknown as RequestUrlResponse); const result = await service.pushFile('new.md', 'new content', 'main', 'create'); - expect(result).toBe('new.md'); - const calls = vi.mocked(requestUrl).mock.calls; - const lastCallParams = calls[calls.length - 1]; - if (!lastCallParams) throw new Error('lastCall is undefined'); - const lastCall = lastCallParams[0] as RequestUrlParam; - expect(lastCall.method).toBe('PUT'); - expect(lastCall.body).not.toContain('"sha":'); + expect(result).toEqual({ path: 'new.md', sha: 'new-sha' }); + const call = getLastRequestCall(); + expect(call.method).toBe('PUT'); + expect(call.body).not.toContain('"sha":'); }); it('should update existing file correctly (sha provided)', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 200, - json: { content: { path: 'existing.md' } } - } as unknown as RequestUrlResponse); + mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } }); const result = await service.pushFile('existing.md', 'updated content', 'main', 'update', 'old-sha'); - expect(result).toBe('existing.md'); - const calls = vi.mocked(requestUrl).mock.calls; - const lastCallParams = calls[calls.length - 1]; - if (!lastCallParams) throw new Error('lastCall is undefined'); - const lastCall = lastCallParams[0] as RequestUrlParam; - expect(lastCall.method).toBe('PUT'); - expect(lastCall.body).toContain('"sha":"old-sha"'); + expect(result).toEqual({ path: 'existing.md', sha: 'updated-sha' }); + const call = getLastRequestCall(); + expect(call.method).toBe('PUT'); + expect(call.body).toContain('"sha":"old-sha"'); }); }); describe('listFiles', () => { it('should list blob files from tree API', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 200, - json: { - tree: [ - { path: 'file1.md', type: 'blob' }, - { path: 'dir/file2.md', type: 'blob' }, - { path: 'subdir', type: 'tree' }, - ] - } - } as unknown as RequestUrlResponse); - - const result = await service.listFiles('main'); - expect(result).toEqual(['file1.md', 'dir/file2.md']); + mockRequest({ status: 200, json: { tree: [ + { path: 'file1.md', type: 'blob' }, + { path: 'dir/file2.md', type: 'blob' }, + { path: 'subdir', type: 'tree' }, + ] } }); + expect(await service.listFiles('main')).toEqual(['file1.md', 'dir/file2.md']); }); it('should filter by rootPath when set', async () => { service.updateConfig(token, owner, repo, 'vault'); - vi.mocked(requestUrl).mockResolvedValue({ - status: 200, - json: { - tree: [ - { path: 'vault/file1.md', type: 'blob' }, - { path: 'other/file2.md', type: 'blob' }, - ] - } - } as unknown as RequestUrlResponse); + mockRequest({ status: 200, json: { tree: [ + { path: 'vault/file1.md', type: 'blob' }, + { path: 'other/file2.md', type: 'blob' }, + ] } }); + expect(await service.listFiles('main')).toEqual(['vault/file1.md']); + }); - const result = await service.listFiles('main'); - expect(result).toEqual(['vault/file1.md']); + it('should not match sibling paths with same prefix as rootPath', async () => { + service.updateConfig(token, owner, repo, 'src/content'); + mockRequest({ status: 200, json: { tree: [ + { path: 'src/content/index.md', type: 'blob' }, + { path: 'src/content.config.ts', type: 'blob' }, + { path: 'src/contentful.ts', type: 'blob' }, + ] } }); + expect(await service.listFiles('main')).toEqual(['src/content/index.md']); }); }); describe('deleteFile', () => { it('should delete file using its sha', async () => { vi.mocked(requestUrl) - .mockResolvedValueOnce({ - status: 200, - json: { content: btoa('content'), sha: 'file-sha' } - } as unknown as RequestUrlResponse) - .mockResolvedValueOnce({ - status: 200, - json: {} - } as unknown as RequestUrlResponse); + .mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); await service.deleteFile('test.md', 'main', 'delete test.md'); @@ -143,50 +129,14 @@ describe('GitHubService', () => { }); describe('testConnection', () => { - it('should return true on successful connection', async () => { - vi.mocked(requestUrl).mockResolvedValue({ status: 200, json: {} } as unknown as RequestUrlResponse); - const result = await service.testConnection(); - expect(result).toBe(true); - }); - - it('should return false on failed connection', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 401, - json: { message: 'Unauthorized' }, - text: 'Unauthorized' - } as unknown as RequestUrlResponse); - const result = await service.testConnection(); - expect(result).toBe(false); - }); + sharedTestConnection(() => service); }); describe('getRepoGitignores', () => { - it('should return only .gitignore paths from file list', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 200, - json: { - tree: [ - { path: '.gitignore', type: 'blob' }, - { path: 'src/main.ts', type: 'blob' }, - { path: 'sub/.gitignore', type: 'blob' }, - ] - } - } as unknown as RequestUrlResponse); - - const result = await service.getRepoGitignores('main'); - expect(result).toEqual(['.gitignore', 'sub/.gitignore']); - }); + sharedGetRepoGitignores(() => service, 'tree'); }); describe('getFile error handling', () => { - it('should rethrow non-404 errors', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 500, - json: { message: 'Internal Server Error' }, - text: 'Internal Server Error' - } as unknown as RequestUrlResponse); - - await expect(service.getFile('test.md', 'main')).rejects.toThrow('500'); - }); + sharedGetFileErrorHandling(() => service); }); }); diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index 38aaa28..8c6fa56 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GitLabService } from '../../src/services/gitlab-service'; -import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian'; +import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers'; describe('GitLabService', () => { let service: GitLabService; @@ -16,44 +16,24 @@ describe('GitLabService', () => { describe('getFile', () => { it('should fetch and decode file content correctly', async () => { - const mockResponse = { - status: 200, - json: { - content: btoa('hello world'), - last_commit_id: 'test-commit-id' - } - } as unknown as RequestUrlResponse; - vi.mocked(requestUrl).mockResolvedValue(mockResponse); - + mockRequest({ status: 200, json: { content: btoa('hello world'), last_commit_id: 'test-commit-id' } }); const result = await service.getFile('test.md', 'main'); - expect(result.content).toBe('hello world'); expect(result.sha).toBe('test-commit-id'); - - const calls = vi.mocked(requestUrl).mock.calls; - const lastCallParams = calls[0]; - if (!lastCallParams) throw new Error('requestUrl was not called'); - const lastCall = lastCallParams[0] as RequestUrlParam; - expect(lastCall.method).toBe('GET'); - expect(lastCall.headers).toMatchObject({ 'PRIVATE-TOKEN': token }); + const call = getLastRequestCall(); + expect(call.method).toBe('GET'); + expect(call.headers).toMatchObject({ 'PRIVATE-TOKEN': token }); }); it('should handle 404 correctly in getFile and return empty content', async () => { - vi.mocked(requestUrl).mockResolvedValue({ status: 404 } as unknown as RequestUrlResponse); + mockRequest({ status: 404 }); const result = await service.getFile('missing.md', 'main'); expect(result.content).toBe(''); expect(result.sha).toBe(''); }); it('should return last_commit_id as sha', async () => { - const mockResponse = { - status: 200, - json: { - content: btoa('test content'), - last_commit_id: 'test-last-commit-id' - } - } as unknown as RequestUrlResponse; - vi.mocked(requestUrl).mockResolvedValue(mockResponse); + mockRequest({ status: 200, json: { content: btoa('test content'), last_commit_id: 'test-last-commit-id' } }); const result = await service.getFile('test.md', 'main'); expect(result.sha).toBe('test-last-commit-id'); }); @@ -61,123 +41,73 @@ describe('GitLabService', () => { describe('pushFile', () => { it('should push file content correctly (POST for new file)', async () => { - vi.mocked(requestUrl).mockResolvedValue({ status: 201, json: { file_path: 'test.md' } } as unknown as RequestUrlResponse); - + mockRequest({ status: 201, json: { file_path: 'test.md' } }); const result = await service.pushFile('test.md', 'new content', 'main', 'initial commit'); - - expect(result).toBe('test.md'); - const calls = vi.mocked(requestUrl).mock.calls; - const lastCallParams = calls[calls.length - 1]; - if (!lastCallParams) throw new Error('requestUrl was not called'); - const lastCall = lastCallParams[0] as RequestUrlParam; - expect(lastCall.method).toBe('POST'); - expect(lastCall.body).toContain(btoa('new content')); + expect(result).toEqual({ path: 'test.md' }); + const call = getLastRequestCall(); + expect(call.method).toBe('POST'); + expect(call.body).toContain(btoa('new content')); }); it('should push file content correctly (PUT for existing file)', async () => { - vi.mocked(requestUrl).mockResolvedValue({ status: 200, json: { file_path: 'test.md' } } as unknown as RequestUrlResponse); - + mockRequest({ status: 200, json: { file_path: 'test.md' } }); const result = await service.pushFile('test.md', 'updated content', 'main', 'update', 'old-sha'); - - expect(result).toBe('test.md'); - const calls = vi.mocked(requestUrl).mock.calls; - const lastCallParams = calls[calls.length - 1]; - if (!lastCallParams) throw new Error('requestUrl was not called'); - const lastCall = lastCallParams[0] as RequestUrlParam; - expect(lastCall.method).toBe('PUT'); - expect(lastCall.body).toContain(btoa('updated content')); + expect(result).toEqual({ path: 'test.md' }); + const call = getLastRequestCall(); + expect(call.method).toBe('PUT'); + expect(call.body).toContain(btoa('updated content')); }); }); describe('listFiles', () => { it('should list blob files from tree API', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 200, - json: [ - { path: 'file1.md', type: 'blob' }, - { path: 'dir/file2.md', type: 'blob' }, - { path: 'subdir', type: 'tree' }, - ] - } as unknown as RequestUrlResponse); - - const result = await service.listFiles('main'); - expect(result).toEqual(['file1.md', 'dir/file2.md']); + mockRequest({ status: 200, json: [ + { path: 'file1.md', type: 'blob' }, + { path: 'dir/file2.md', type: 'blob' }, + { path: 'subdir', type: 'tree' }, + ] }); + expect(await service.listFiles('main')).toEqual(['file1.md', 'dir/file2.md']); }); it('should filter by rootPath when set', async () => { service.updateConfig(baseUrl, token, projectId, 'vault'); - vi.mocked(requestUrl).mockResolvedValue({ - status: 200, - json: [ - { path: 'vault/file1.md', type: 'blob' }, - { path: 'other/file2.md', type: 'blob' }, - ] - } as unknown as RequestUrlResponse); + mockRequest({ status: 200, json: [ + { path: 'vault/file1.md', type: 'blob' }, + { path: 'other/file2.md', type: 'blob' }, + ] }); + expect(await service.listFiles('main')).toEqual(['vault/file1.md']); + }); - const result = await service.listFiles('main'); - expect(result).toEqual(['vault/file1.md']); + it('should not match sibling paths with same prefix as rootPath', async () => { + service.updateConfig(baseUrl, token, projectId, 'src/content'); + mockRequest({ status: 200, json: [ + { path: 'src/content/index.md', type: 'blob' }, + { path: 'src/content.config.ts', type: 'blob' }, + { path: 'src/contentful.ts', type: 'blob' }, + ] }); + expect(await service.listFiles('main')).toEqual(['src/content/index.md']); }); }); describe('deleteFile', () => { it('should delete file with commit message', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 200, - json: {} - } as unknown as RequestUrlResponse); - + mockRequest({ status: 200, json: {} }); await service.deleteFile('test.md', 'main', 'delete test.md'); - - const calls = vi.mocked(requestUrl).mock.calls; - const deleteCall = calls[0]?.[0] as RequestUrlParam; - expect(deleteCall.method).toBe('DELETE'); - expect(deleteCall.body).toContain('"commit_message":"delete test.md"'); + const call = getLastRequestCall(); + expect(call.method).toBe('DELETE'); + expect(call.body).toContain('"commit_message":"delete test.md"'); }); }); describe('testConnection', () => { - it('should return true on successful connection', async () => { - vi.mocked(requestUrl).mockResolvedValue({ status: 200, json: {} } as unknown as RequestUrlResponse); - const result = await service.testConnection(); - expect(result).toBe(true); - }); - - it('should return false on failed connection', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 401, - json: { message: 'Unauthorized' }, - text: 'Unauthorized' - } as unknown as RequestUrlResponse); - const result = await service.testConnection(); - expect(result).toBe(false); - }); + sharedTestConnection(() => service); }); describe('getRepoGitignores', () => { - it('should return only .gitignore paths from file list', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 200, - json: [ - { path: '.gitignore', type: 'blob' }, - { path: 'src/main.ts', type: 'blob' }, - { path: 'sub/.gitignore', type: 'blob' }, - ] - } as unknown as RequestUrlResponse); - - const result = await service.getRepoGitignores('main'); - expect(result).toEqual(['.gitignore', 'sub/.gitignore']); - }); + sharedGetRepoGitignores(() => service, 'json'); }); describe('getFile error handling', () => { - it('should rethrow non-404 errors', async () => { - vi.mocked(requestUrl).mockResolvedValue({ - status: 500, - json: { message: 'Internal Server Error' }, - text: 'Internal Server Error' - } as unknown as RequestUrlResponse); - - await expect(service.getFile('test.md', 'main')).rejects.toThrow('500'); - }); + sharedGetFileErrorHandling(() => service); }); }); diff --git a/tests/services/service-test-helpers.ts b/tests/services/service-test-helpers.ts new file mode 100644 index 0000000..5570f4a --- /dev/null +++ b/tests/services/service-test-helpers.ts @@ -0,0 +1,45 @@ +import { it, expect, vi } from 'vitest'; +import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian'; +import type { GitServiceInterface } from '../../src/services/git-service-interface'; + +export function getLastRequestCall(): RequestUrlParam { + const calls = vi.mocked(requestUrl).mock.calls; + const last = calls[calls.length - 1]; + if (!last) throw new Error('requestUrl was not called'); + return last[0] as RequestUrlParam; +} + +export function mockRequest(response: Partial): void { + vi.mocked(requestUrl).mockResolvedValue(response as RequestUrlResponse); +} + +export function sharedTestConnection(getService: () => GitServiceInterface): void { + it('should return true on successful connection', async () => { + mockRequest({ status: 200, json: {} }); + expect(await getService().testConnection()).toBe(true); + }); + + it('should return false on failed connection', async () => { + mockRequest({ status: 401, json: { message: 'Unauthorized' }, text: 'Unauthorized' }); + expect(await getService().testConnection()).toBe(false); + }); +} + +export function sharedGetFileErrorHandling(getService: () => GitServiceInterface): void { + it('should rethrow non-404 errors', async () => { + mockRequest({ status: 500, json: { message: 'Internal Server Error' }, text: 'Internal Server Error' }); + await expect(getService().getFile('test.md', 'main')).rejects.toThrow('500'); + }); +} + +export function sharedGetRepoGitignores(getService: () => GitServiceInterface, treeKey: 'tree' | 'json'): void { + it('should return only .gitignore paths from file list', async () => { + const items = [ + { path: '.gitignore', type: 'blob' }, + { path: 'src/main.ts', type: 'blob' }, + { path: 'sub/.gitignore', type: 'blob' }, + ]; + mockRequest({ status: 200, json: treeKey === 'tree' ? { tree: items } : items }); + expect(await getService().getRepoGitignores('main')).toEqual(['.gitignore', 'sub/.gitignore']); + }); +} diff --git a/tests/setup.ts b/tests/setup.ts index da68ab4..805eed1 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -55,6 +55,7 @@ export const App = class { export const TFile = class {}; export const requestUrl = vi.fn(); +export const setTooltip = vi.fn(); vi.mock('obsidian', () => ({ Plugin, @@ -67,4 +68,5 @@ vi.mock('obsidian', () => ({ App, TFile, requestUrl, + setTooltip, })); diff --git a/tests/ui/ActionBar.test.ts b/tests/ui/ActionBar.test.ts new file mode 100644 index 0000000..04bbae5 --- /dev/null +++ b/tests/ui/ActionBar.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +import { renderActionBar, type ActionBarProps, type ActionBarCallbacks } from '../../src/ui/components/ActionBar'; +import { setupObsidianDOM, createContainer } from './setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +const baseProps = (overrides?: Partial): ActionBarProps => ({ + hasFiles: true, allSelected: false, indeterminate: false, + canPush: 1, canPull: 1, canDelete: 1, + ...overrides, +}); + +describe('renderActionBar', () => { + let container: HTMLElement; + let callbacks: ActionBarCallbacks; + + beforeEach(() => { + container = createContainer(); + callbacks = { + onRefresh: vi.fn(), + onSelectAll: vi.fn(), + onPush: vi.fn(), + onPull: vi.fn(), + onDelete: vi.fn(), + }; + }); + + describe('refresh button', () => { + it('always renders when hasFiles is false', () => { + renderActionBar(container, baseProps({ hasFiles: false }), callbacks); + expect(container.querySelector('.ssv-btn-refresh')).not.toBeNull(); + }); + + it('calls onRefresh when clicked', () => { + renderActionBar(container, baseProps({ hasFiles: false }), callbacks); + (container.querySelector('.ssv-btn-refresh') as HTMLButtonElement).click(); + expect(callbacks.onRefresh).toHaveBeenCalledOnce(); + }); + }); + + describe('when hasFiles is false', () => { + it('does not render push / pull / delete buttons', () => { + renderActionBar(container, baseProps({ hasFiles: false }), callbacks); + expect(container.querySelector('.ssv-btn-push')).toBeNull(); + expect(container.querySelector('.ssv-btn-pull')).toBeNull(); + expect(container.querySelector('.ssv-btn-danger')).toBeNull(); + }); + + it('does not render select-all row', () => { + renderActionBar(container, baseProps({ hasFiles: false }), callbacks); + expect(container.querySelector('.ssv-select-row')).toBeNull(); + }); + }); + + describe('when hasFiles is true', () => { + it('renders push, pull, and delete buttons', () => { + renderActionBar(container, baseProps(), callbacks); + expect(container.querySelector('.ssv-btn-push')).not.toBeNull(); + expect(container.querySelector('.ssv-btn-pull')).not.toBeNull(); + expect(container.querySelector('.ssv-btn-danger')).not.toBeNull(); + }); + + it('renders select-all checkbox', () => { + renderActionBar(container, baseProps(), callbacks); + expect(container.querySelector('.ssv-select-row input[type="checkbox"]')).not.toBeNull(); + }); + + it('calls onPush when push button clicked', () => { + renderActionBar(container, baseProps(), callbacks); + (container.querySelector('.ssv-btn-push') as HTMLButtonElement).click(); + expect(callbacks.onPush).toHaveBeenCalledOnce(); + }); + + it('calls onPull when pull button clicked', () => { + renderActionBar(container, baseProps(), callbacks); + (container.querySelector('.ssv-btn-pull') as HTMLButtonElement).click(); + expect(callbacks.onPull).toHaveBeenCalledOnce(); + }); + + it('calls onDelete when delete button clicked', () => { + renderActionBar(container, baseProps(), callbacks); + (container.querySelector('.ssv-btn-danger') as HTMLButtonElement).click(); + expect(callbacks.onDelete).toHaveBeenCalledOnce(); + }); + + it('push button is disabled when canPush is 0', () => { + renderActionBar(container, baseProps({ canPush: 0 }), callbacks); + expect((container.querySelector('.ssv-btn-push') as HTMLButtonElement).disabled).toBe(true); + }); + + it('pull button is disabled when canPull is 0', () => { + renderActionBar(container, baseProps({ canPull: 0 }), callbacks); + expect((container.querySelector('.ssv-btn-pull') as HTMLButtonElement).disabled).toBe(true); + }); + + it('delete button is disabled when canDelete is 0', () => { + renderActionBar(container, baseProps({ canDelete: 0 }), callbacks); + expect((container.querySelector('.ssv-btn-danger') as HTMLButtonElement).disabled).toBe(true); + }); + + it('push button is enabled when canPush > 0', () => { + renderActionBar(container, baseProps({ canPush: 3 }), callbacks); + expect((container.querySelector('.ssv-btn-push') as HTMLButtonElement).disabled).toBe(false); + }); + + it('select-all checkbox reflects allSelected prop', () => { + renderActionBar(container, baseProps({ allSelected: true }), callbacks); + const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement; + expect(cb.checked).toBe(true); + }); + + it('calls onSelectAll(true) when checkbox is checked', () => { + renderActionBar(container, baseProps(), callbacks); + const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement; + cb.checked = true; + cb.dispatchEvent(new Event('change')); + expect(callbacks.onSelectAll).toHaveBeenCalledWith(true); + }); + + it('calls onSelectAll(false) when checkbox is unchecked', () => { + renderActionBar(container, baseProps({ allSelected: true }), callbacks); + const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement; + cb.checked = false; + cb.dispatchEvent(new Event('change')); + expect(callbacks.onSelectAll).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/tests/ui/DiffPanel.test.ts b/tests/ui/DiffPanel.test.ts new file mode 100644 index 0000000..eb7410a --- /dev/null +++ b/tests/ui/DiffPanel.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { renderDiffPanel } from '../../src/ui/components/DiffPanel'; +import { setupObsidianDOM, createContainer } from './setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +describe('renderDiffPanel', () => { + let container: HTMLElement; + + beforeEach(() => { + container = createContainer(); + }); + + it('returns the diff element with ssv-diff class', () => { + const el = renderDiffPanel(container, '', ''); + expect(el.classList.contains('ssv-diff')).toBe(true); + }); + + it('renders Remote and Local column headers', () => { + renderDiffPanel(container, 'a', 'b'); + const headers = container.querySelectorAll('.ssv-diff-hd'); + expect(headers[0]?.textContent).toBe('Remote'); + expect(headers[1]?.textContent).toBe('Local'); + }); + + it('renders unchanged lines with unchanged class in both columns', () => { + renderDiffPanel(container, 'same', 'same'); + const cells = container.querySelectorAll('.ssv-diff-cell.unchanged'); + expect(cells.length).toBeGreaterThanOrEqual(2); + }); + + it('renders added line in unified view', () => { + renderDiffPanel(container, '', 'new line'); + const added = container.querySelector('.ssv-u-line.added'); + expect(added?.textContent).toContain('new line'); + }); + + it('renders removed line in unified view', () => { + renderDiffPanel(container, 'old line', ''); + const removed = container.querySelector('.ssv-u-line.removed'); + expect(removed?.textContent).toContain('old line'); + }); + + it('renders both removed and added lines for a replacement', () => { + renderDiffPanel(container, 'before', 'after'); + expect(container.querySelector('.ssv-u-line.removed')).not.toBeNull(); + expect(container.querySelector('.ssv-u-line.added')).not.toBeNull(); + }); + + it('renders unchanged lines in unified view for identical content', () => { + renderDiffPanel(container, 'context', 'context'); + expect(container.querySelector('.ssv-u-line.unchanged')).not.toBeNull(); + }); +}); diff --git a/tests/ui/FileListItem.test.ts b/tests/ui/FileListItem.test.ts new file mode 100644 index 0000000..7983c3c --- /dev/null +++ b/tests/ui/FileListItem.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +import { renderFileItem, statusMeta, type FileItemCallbacks } from '../../src/ui/components/FileListItem'; +import type { FileStatus } from '../../src/ui/types'; +import { TFile } from 'obsidian'; +import { setupObsidianDOM, createContainer } from './setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +const mockFile = Object.assign(new TFile(), { path: 'docs/test.md' }); + +function makeFileStatus(status: FileStatus['status'], overrides?: Partial): FileStatus { + return { path: 'docs/test.md', status, ...overrides }; +} + +describe('statusMeta', () => { + it.each([ + ['synced', '✓', 'Synced', 'status-synced'], + ['modified', '⚠', 'Changed', 'status-modified'], + ['unsynced', '↑', 'Local only', 'status-unsynced'], + ['remote-only', '↓', 'Remote', 'status-remote'], + ['checking', '⟳', 'Checking', 'status-checking'], + ] as const)('%s: returns correct icon, label, and fileCls', (status, icon, label, fileCls) => { + const meta = statusMeta(status); + expect(meta.icon).toBe(icon); + expect(meta.label).toBe(label); + expect(meta.fileCls).toBe(fileCls); + }); + + it('returns distinct CSS classes for each status', () => { + const statuses = ['synced', 'modified', 'unsynced', 'remote-only', 'checking'] as const; + const badgeCls = statuses.map(s => statusMeta(s).badgeCls); + expect(new Set(badgeCls).size).toBe(statuses.length); + }); +}); + +describe('renderFileItem', () => { + let container: HTMLElement; + let callbacks: FileItemCallbacks; + + beforeEach(() => { + container = createContainer(); + callbacks = { + onSelect: vi.fn(), + onPush: vi.fn(), + onPull: vi.fn(), + onDelete: vi.fn(), + }; + }); + + it('renders file path', () => { + renderFileItem(container, makeFileStatus('synced'), false, callbacks); + expect(container.querySelector('.ssv-file-path')?.textContent).toBe('docs/test.md'); + }); + + it('renders status badge with correct label', () => { + renderFileItem(container, makeFileStatus('modified'), false, callbacks); + expect(container.querySelector('.ssv-status-badge')?.textContent).toBe('Changed'); + }); + + it('checkbox reflects isSelected=true', () => { + renderFileItem(container, makeFileStatus('synced'), true, callbacks); + expect((container.querySelector('.ssv-file-checkbox') as HTMLInputElement).checked).toBe(true); + }); + + it('checkbox reflects isSelected=false', () => { + renderFileItem(container, makeFileStatus('synced'), false, callbacks); + expect((container.querySelector('.ssv-file-checkbox') as HTMLInputElement).checked).toBe(false); + }); + + it('calls onSelect(path, true) when checkbox checked', () => { + renderFileItem(container, makeFileStatus('synced'), false, callbacks); + const cb = container.querySelector('.ssv-file-checkbox') as HTMLInputElement; + cb.checked = true; + cb.dispatchEvent(new Event('change')); + expect(callbacks.onSelect).toHaveBeenCalledWith('docs/test.md', true); + }); + + it('calls onSelect(path, false) when checkbox unchecked', () => { + renderFileItem(container, makeFileStatus('synced'), true, callbacks); + const cb = container.querySelector('.ssv-file-checkbox') as HTMLInputElement; + cb.checked = false; + cb.dispatchEvent(new Event('change')); + expect(callbacks.onSelect).toHaveBeenCalledWith('docs/test.md', false); + }); + + describe('synced file', () => { + it('renders no action buttons', () => { + renderFileItem(container, makeFileStatus('synced'), false, callbacks); + expect(container.querySelector('.ssv-file-actions')).toBeNull(); + }); + }); + + describe('checking file', () => { + it('renders no action buttons', () => { + renderFileItem(container, makeFileStatus('checking'), false, callbacks); + expect(container.querySelector('.ssv-file-actions')).toBeNull(); + }); + }); + + describe('modified file', () => { + it('renders push and pull buttons when file exists', () => { + const fs = makeFileStatus('modified', { file: mockFile }); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-action-btn.push')).not.toBeNull(); + expect(container.querySelector('.ssv-action-btn.pull')).not.toBeNull(); + }); + + it('calls onPush with fileStatus when push clicked', () => { + const fs = makeFileStatus('modified', { file: mockFile }); + renderFileItem(container, fs, false, callbacks); + (container.querySelector('.ssv-action-btn.push') as HTMLButtonElement).click(); + expect(callbacks.onPush).toHaveBeenCalledWith(fs); + }); + + it('calls onPull with fileStatus when pull clicked', () => { + const fs = makeFileStatus('modified', { file: mockFile }); + renderFileItem(container, fs, false, callbacks); + (container.querySelector('.ssv-action-btn.pull') as HTMLButtonElement).click(); + expect(callbacks.onPull).toHaveBeenCalledWith(fs); + }); + + it('renders diff toggle button when diff content is present', () => { + const fs = makeFileStatus('modified', { diff: 'some diff', localContent: 'b', remoteContent: 'a' }); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-action-btn.diff')).not.toBeNull(); + }); + + it('does not render diff toggle when no diff', () => { + const fs = makeFileStatus('modified', { file: mockFile }); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-action-btn.diff')).toBeNull(); + }); + + it('diff panel is not visible before toggle', () => { + const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' }); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false); + }); + + it('diff panel becomes visible on first click', () => { + const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' }); + renderFileItem(container, fs, false, callbacks); + (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); + expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(true); + }); + + it('diff panel hides on second click', () => { + const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' }); + renderFileItem(container, fs, false, callbacks); + const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement; + btn.click(); + btn.click(); + expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false); + }); + + it('diff button label toggles between " Diff" and " Hide"', () => { + const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' }); + renderFileItem(container, fs, false, callbacks); + const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement; + const label = btn.querySelector('.ssv-btn-label') as HTMLElement; + expect(label.textContent).toBe(' Diff'); + btn.click(); + expect(label.textContent).toBe(' Hide'); + btn.click(); + expect(label.textContent).toBe(' Diff'); + }); + }); + + describe('unsynced file', () => { + it('renders push button when file exists', () => { + const fs = makeFileStatus('unsynced', { file: mockFile }); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-action-btn.push')).not.toBeNull(); + }); + + it('renders delete button when file exists', () => { + const fs = makeFileStatus('unsynced', { file: mockFile }); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-action-btn.danger')).not.toBeNull(); + }); + + it('does not render pull button', () => { + const fs = makeFileStatus('unsynced', { file: mockFile }); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-action-btn.pull')).toBeNull(); + }); + + it('calls onDelete with fileStatus when delete clicked', () => { + const fs = makeFileStatus('unsynced', { file: mockFile }); + renderFileItem(container, fs, false, callbacks); + (container.querySelector('.ssv-action-btn.danger') as HTMLButtonElement).click(); + expect(callbacks.onDelete).toHaveBeenCalledWith(fs); + }); + }); + + describe('remote-only file', () => { + it('renders pull button', () => { + const fs = makeFileStatus('remote-only'); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-action-btn.pull')).not.toBeNull(); + }); + + it('does not render push button', () => { + const fs = makeFileStatus('remote-only'); + renderFileItem(container, fs, false, callbacks); + expect(container.querySelector('.ssv-action-btn.push')).toBeNull(); + }); + + it('calls onPull with fileStatus when pull clicked', () => { + const fs = makeFileStatus('remote-only'); + renderFileItem(container, fs, false, callbacks); + (container.querySelector('.ssv-action-btn.pull') as HTMLButtonElement).click(); + expect(callbacks.onPull).toHaveBeenCalledWith(fs); + }); + }); +}); diff --git a/tests/ui/setup-dom.ts b/tests/ui/setup-dom.ts new file mode 100644 index 0000000..3213df0 --- /dev/null +++ b/tests/ui/setup-dom.ts @@ -0,0 +1,67 @@ +/** + * Sets up a JSDOM environment for DOM-based tests running in the node vitest environment. + * Call `setupObsidianDOM()` inside `beforeAll`, and use `createContainer()` for fresh roots. + */ +import { JSDOM } from 'jsdom'; + +let dom: JSDOM; + +export function setupObsidianDOM(): void { + dom = new JSDOM(''); + const { window } = dom; + + Object.assign(globalThis, { + document: window.document, + window: window, + HTMLElement: window.HTMLElement, + HTMLInputElement: window.HTMLInputElement, + Event: window.Event, + Text: window.Text, + }); + + const proto = window.HTMLElement.prototype; + if ('createEl' in proto) return; + + type DomOpts = { cls?: string; text?: string; type?: string }; + const toOpts = (o?: DomOpts | string): DomOpts => (typeof o === 'string' ? { cls: o } : o ?? {}); + + function applyOpts(el: Element, o: DomOpts): void { + if (o.cls) el.className = o.cls; + if (o.text) el.textContent = o.text; + if (o.type) (el as HTMLInputElement).type = o.type; + } + + Object.assign(proto, { + createEl(tag: K, opts?: DomOpts | string): HTMLElementTagNameMap[K] { + const el = window.document.createElement(tag); + applyOpts(el, toOpts(opts)); + (this as HTMLElement).appendChild(el); + return el as unknown as HTMLElementTagNameMap[K]; + }, + createDiv(opts?: DomOpts | string): HTMLDivElement { + const el = window.document.createElement('div'); + applyOpts(el, toOpts(opts)); + (this as HTMLElement).appendChild(el); + return el as unknown as HTMLDivElement; + }, + createSpan(opts?: DomOpts | string): HTMLSpanElement { + const el = window.document.createElement('span'); + applyOpts(el, toOpts(opts)); + (this as HTMLElement).appendChild(el); + return el as unknown as HTMLSpanElement; + }, + hasClass(cls: string): boolean { + return (this as HTMLElement).classList.contains(cls); + }, + toggleClass(cls: string, value: boolean): void { + (this as HTMLElement).classList.toggle(cls, value); + }, + setText(text: string): void { + (this as HTMLElement).textContent = text; + }, + }); +} + +export function createContainer(): HTMLElement { + return dom.window.document.createElement('div') as unknown as HTMLElement; +} diff --git a/tests/utils/diff.test.ts b/tests/utils/diff.test.ts new file mode 100644 index 0000000..e0a9fa2 --- /dev/null +++ b/tests/utils/diff.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest'; +import { computeSideBySideDiff } from '../../src/utils/diff'; + +describe('computeSideBySideDiff', () => { + describe('identical content', () => { + it('returns all unchanged rows for identical single-line content', () => { + const rows = computeSideBySideDiff('hello', 'hello'); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + left: { lineNum: 1, content: 'hello', type: 'unchanged' }, + right: { lineNum: 1, content: 'hello', type: 'unchanged' }, + }); + }); + + it('returns all unchanged rows for identical multi-line content', () => { + const text = 'line1\nline2\nline3'; + const rows = computeSideBySideDiff(text, text); + expect(rows).toHaveLength(3); + rows.forEach(row => { + expect(row.left.type).toBe('unchanged'); + expect(row.right.type).toBe('unchanged'); + }); + }); + + it('handles empty strings', () => { + const rows = computeSideBySideDiff('', ''); + expect(rows).toHaveLength(1); + expect(rows[0]!.left.type).toBe('unchanged'); + }); + }); + + describe('CRLF normalisation', () => { + it('treats CRLF and LF as identical', () => { + const rows = computeSideBySideDiff('a\r\nb', 'a\nb'); + expect(rows).toHaveLength(2); + rows.forEach(row => expect(row.left.type).toBe('unchanged')); + }); + + it('treats CR-only line endings as identical', () => { + const rows = computeSideBySideDiff('a\rb', 'a\nb'); + expect(rows).toHaveLength(2); + rows.forEach(row => expect(row.left.type).toBe('unchanged')); + }); + }); + + describe('additions only (remote → local adds lines)', () => { + it('detects a single added line with empty phantom left side', () => { + // 'a' is unchanged; 'b' is a pure add — no corresponding removed line → left side is empty + const rows = computeSideBySideDiff('a', 'a\nb'); + const added = rows.find(r => r.right.type === 'added'); + expect(added).toBeDefined(); + expect(added!.right.content).toBe('b'); + expect(added!.left.type).toBe('empty'); + expect(added!.left.lineNum).toBeNull(); + }); + + it('detects multiple added lines', () => { + const rows = computeSideBySideDiff('a', 'a\nb\nc'); + const addedRows = rows.filter(r => r.right.type === 'added'); + expect(addedRows).toHaveLength(2); + expect(addedRows.map(r => r.right.content)).toEqual(['b', 'c']); + }); + + it('treats single-line change as removed+added, not empty+added', () => { + // Both sides have exactly one (different) line — no phantom empty side + const rows = computeSideBySideDiff('old', 'new'); + expect(rows).toHaveLength(1); + expect(rows[0]!.left.type).toBe('removed'); + expect(rows[0]!.right.type).toBe('added'); + }); + }); + + describe('removals only (remote has extra lines vs local)', () => { + it('detects a single removed line with empty phantom right side', () => { + // 'a' is unchanged; 'b' is a pure remove — no corresponding added line → right side is empty + const rows = computeSideBySideDiff('a\nb', 'a'); + const removed = rows.find(r => r.left.type === 'removed'); + expect(removed).toBeDefined(); + expect(removed!.left.content).toBe('b'); + expect(removed!.right.type).toBe('empty'); + expect(removed!.right.lineNum).toBeNull(); + }); + + it('detects multiple removed lines', () => { + const rows = computeSideBySideDiff('a\nb\nc', 'a'); + const removedRows = rows.filter(r => r.left.type === 'removed'); + expect(removedRows).toHaveLength(2); + expect(removedRows.map(r => r.left.content)).toEqual(['b', 'c']); + }); + }); + + describe('mixed changes', () => { + it('correctly pairs removed and added lines', () => { + const rows = computeSideBySideDiff('old line', 'new line'); + expect(rows).toHaveLength(1); + expect(rows[0]!.left.type).toBe('removed'); + expect(rows[0]!.left.content).toBe('old line'); + expect(rows[0]!.right.type).toBe('added'); + expect(rows[0]!.right.content).toBe('new line'); + }); + + it('preserves unchanged lines between changes', () => { + const remote = 'header\nold body\nfooter'; + const local = 'header\nnew body\nfooter'; + const rows = computeSideBySideDiff(remote, local); + + const unchanged = rows.filter(r => r.left.type === 'unchanged'); + expect(unchanged).toHaveLength(2); + expect(unchanged[0]!.left.content).toBe('header'); + expect(unchanged[1]!.left.content).toBe('footer'); + + const changed = rows.find(r => r.left.type === 'removed'); + expect(changed!.left.content).toBe('old body'); + expect(changed!.right.content).toBe('new body'); + }); + }); + + describe('line numbers', () => { + it('assigns correct 1-based line numbers to unchanged rows', () => { + const rows = computeSideBySideDiff('a\nb\nc', 'a\nb\nc'); + rows.forEach((row, i) => { + expect(row.left.lineNum).toBe(i + 1); + expect(row.right.lineNum).toBe(i + 1); + }); + }); + + it('assigns null line number to empty (phantom) sides', () => { + // Pure add: 'a' unchanged, 'b' added with no counterpart on the left + const rows = computeSideBySideDiff('a', 'a\nb'); + const emptyRow = rows.find(r => r.left.type === 'empty'); + expect(emptyRow).toBeDefined(); + expect(emptyRow!.left.lineNum).toBeNull(); + expect(emptyRow!.left.content).toBeNull(); + }); + }); + + describe('large file fallback (simpleDiff)', () => { + it('falls back to simpleDiff for files exceeding LCS threshold', () => { + // Create inputs where m*n > 250_000 to trigger simpleDiff + const longRemote = Array.from({ length: 600 }, (_, i) => `remote line ${i}`).join('\n'); + const longLocal = Array.from({ length: 600 }, (_, i) => `local line ${i}`).join('\n'); + + const rows = computeSideBySideDiff(longRemote, longLocal); + expect(rows).toHaveLength(600); + // simpleDiff compares line-by-line; all differ here + rows.forEach(row => { + expect(row.left.type).toBe('removed'); + expect(row.right.type).toBe('added'); + }); + }); + + it('simpleDiff handles remote shorter than local', () => { + const longRemote = Array.from({ length: 600 }, () => 'same').join('\n'); + const longLocal = Array.from({ length: 700 }, () => 'same').join('\n'); + + const rows = computeSideBySideDiff(longRemote, longLocal); + expect(rows).toHaveLength(700); + + // First 600 rows: identical content → unchanged + expect(rows[0]!.left.type).toBe('unchanged'); + // Last 100 rows: added on the right + const extraRows = rows.slice(600); + extraRows.forEach(row => { + expect(row.left.type).toBe('empty'); + expect(row.right.type).toBe('added'); + }); + }); + + it('simpleDiff handles local shorter than remote', () => { + const longRemote = Array.from({ length: 700 }, () => 'same').join('\n'); + const longLocal = Array.from({ length: 600 }, () => 'same').join('\n'); + + const rows = computeSideBySideDiff(longRemote, longLocal); + expect(rows).toHaveLength(700); + + const extraRows = rows.slice(600); + extraRows.forEach(row => { + expect(row.left.type).toBe('removed'); + expect(row.right.type).toBe('empty'); + }); + }); + }); +});