From 5d9cd3345d9ac948e9e46459983817eb74e2f3e2 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Fri, 24 Apr 2026 12:32:22 +0000 Subject: [PATCH] Fix syncing of hidden files inside unindexed directories --- .../skills/obsidian-development}/SKILL.md | 0 .../obsidian-development}/evals/evals.json | 0 src/logic/sync-manager.ts | 195 ++++-- src/main.ts | 12 +- src/services/git-service-interface.ts | 2 + src/services/github-service.ts | 104 ++- src/services/gitlab-service.ts | 62 ++ src/ui/SyncConflictModal.ts | 8 +- src/ui/SyncStatusView.ts | 655 +++++++++++++++++- styles.css | 222 +++++- 10 files changed, 1131 insertions(+), 129 deletions(-) rename {obsidian-development => .claude/skills/obsidian-development}/SKILL.md (100%) rename {obsidian-development => .claude/skills/obsidian-development}/evals/evals.json (100%) diff --git a/obsidian-development/SKILL.md b/.claude/skills/obsidian-development/SKILL.md similarity index 100% rename from obsidian-development/SKILL.md rename to .claude/skills/obsidian-development/SKILL.md diff --git a/obsidian-development/evals/evals.json b/.claude/skills/obsidian-development/evals/evals.json similarity index 100% rename from obsidian-development/evals/evals.json rename to .claude/skills/obsidian-development/evals/evals.json diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index c24d803..525da2b 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -19,47 +19,60 @@ export class SyncManager { this.gitService = gitService; } - async pushFile(file: TFile) { - if (!this.app.vault.getFileByPath(file.path)) { - new Notice(`File ${file.name} no longer exists in vault.`); + async pushFile(fileOrPath: TFile | string) { + const isString = typeof fileOrPath === 'string'; + const path = isString ? fileOrPath : fileOrPath.path; + const name = isString ? path.split('/').pop() || path : fileOrPath.name; + + if (isString) { + if (!(await this.app.vault.adapter.exists(path))) { + new Notice(`File ${name} no longer exists in vault.`); + return; + } + } else if (!this.app.vault.getFileByPath(path)) { + new Notice(`File ${name} no longer exists in vault.`); return; } - const content = await this.app.vault.read(file); + + const content = isString ? await this.app.vault.adapter.read(path) : (fileOrPath instanceof TFile ? await this.app.vault.read(fileOrPath) : ''); const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; try { // Check if this is a renamed file - const renamedFrom = this.detectRename(file); - if (renamedFrom) { - await this.handleRename(file, renamedFrom, content); - return; + let renamedFrom = null; + if (!isString && fileOrPath instanceof TFile) { + renamedFrom = this.detectRename(fileOrPath); + if (renamedFrom) { + await this.handleRename(fileOrPath, renamedFrom, content); + return; + } } // Conflict detection - const remote = await this.gitService.getFile(file.path, this.settings.branch); - const lastSynced = this.settings.syncMetadata[file.path]; + const remote = await this.gitService.getFile(path, this.settings.branch); + const lastSynced = this.settings.syncMetadata[path]; if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) { - new SyncConflictModal(this.app, file, content, remote.content, (choice) => { + new SyncConflictModal(this.app, name, content, remote.content, (choice) => { void (async () => { try { if (choice === 'local') { - await this.performPush(file, content, remote.sha); + await this.performPush({ path, name }, content, remote.sha); } else { - await this.performPull(file, remote.content, remote.sha); + await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha); } } catch (e) { console.error(e); - new Notice(`Failed to resolve conflict for ${file.name}: ${e instanceof Error ? e.message : String(e)}`); + new Notice(`Failed to resolve conflict for ${name}: ${e instanceof Error ? e.message : String(e)}`); } })(); }).open(); return; } - await this.performPush(file, content, remote.sha); + await this.performPush({ path, name }, content, remote.sha); } catch (e) { console.error(e); - new Notice(`Failed to push ${file.name} to ${serviceName}: ${e instanceof Error ? e.message : String(e)}`); + new Notice(`Failed to push ${name} to ${serviceName}: ${e instanceof Error ? e.message : String(e)}`); } } @@ -117,7 +130,7 @@ export class SyncManager { } } - private async performPush(file: TFile, content: string, existingSha?: string) { + private async performPush(file: {path: string, name: string}, content: string, existingSha?: string) { const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; await this.gitService.pushFile( file.path, @@ -139,57 +152,72 @@ export class SyncManager { new Notice(`Pushed ${file.name} to ${serviceName}`); } - async pullFile(file: TFile) { - if (!this.app.vault.getFileByPath(file.path)) { - new Notice(`File ${file.name} no longer exists in vault.`); + async pullFile(fileOrPath: TFile | string) { + const isString = typeof fileOrPath === 'string'; + const path = isString ? fileOrPath : fileOrPath.path; + const name = isString ? path.split('/').pop() || path : fileOrPath.name; + + if (isString) { + if (!(await this.app.vault.adapter.exists(path))) { + new Notice(`File ${name} no longer exists in vault.`); + return; + } + } else if (!this.app.vault.getFileByPath(path)) { + new Notice(`File ${name} no longer exists in vault.`); return; } + const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; try { - const remote = await this.gitService.getFile(file.path, this.settings.branch); - const localContent = await this.app.vault.read(file); - const lastSynced = this.settings.syncMetadata[file.path]; + const remote = await this.gitService.getFile(path, this.settings.branch); + const localContent = isString ? await this.app.vault.adapter.read(path) : (fileOrPath instanceof TFile ? await this.app.vault.read(fileOrPath) : ''); + const lastSynced = this.settings.syncMetadata[path]; if (localContent === remote.content) { // Still update metadata even if content matches - this.settings.syncMetadata[file.path] = { + this.settings.syncMetadata[path] = { lastSyncedSha: remote.sha, lastSyncedAt: Date.now() }; await this.saveSettings(); - new Notice(`${file.name} is already up to date.`); + new Notice(`${name} is already up to date.`); return; } // Conflict detection for pull if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) { - new SyncConflictModal(this.app, file, localContent, remote.content, (choice) => { + new SyncConflictModal(this.app, name, localContent, remote.content, (choice) => { void (async () => { try { if (choice === 'local') { - await this.performPush(file, localContent, remote.sha); + await this.performPush({ path, name }, localContent, remote.sha); } else { - await this.performPull(file, remote.content, remote.sha); + await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha); } } catch (e) { console.error(e); - new Notice(`Failed to resolve conflict for ${file.name}: ${e instanceof Error ? e.message : String(e)}`); + new Notice(`Failed to resolve conflict for ${name}: ${e instanceof Error ? e.message : String(e)}`); } })(); }).open(); return; } - await this.performPull(file, remote.content, remote.sha); + await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha); } catch (e) { console.error(e); - new Notice(`Failed to pull ${file.name} from ${serviceName}: ${e instanceof Error ? e.message : String(e)}`); + new Notice(`Failed to pull ${name} from ${serviceName}: ${e instanceof Error ? e.message : String(e)}`); } } - private async performPull(file: TFile, remoteContent: string, remoteSha: string) { + private async performPull(file: TFile | {path: string, name: string}, remoteContent: string, remoteSha: string) { const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - await this.app.vault.modify(file, remoteContent); + + if (file instanceof TFile || ('vault' in file)) { + await this.app.vault.modify(file as TFile, remoteContent); + } else { + await this.app.vault.adapter.write(file.path, remoteContent); + } // Update metadata this.settings.syncMetadata[file.path] = { @@ -199,7 +227,8 @@ export class SyncManager { }; await this.saveSettings(); - new Notice(`Pulled ${file.name} from ${serviceName}`); + const name = file instanceof TFile ? file.name : file.name; + new Notice(`Pulled ${name} from ${serviceName}`); } private async saveSettings() { @@ -212,50 +241,64 @@ export class SyncManager { /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */ } - async pushAllFiles(files: TFile[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { + async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> }; const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; for (let i = 0; i < files.length; i++) { - const file = files[i]; - if (!file) continue; + const fileOrPath = files[i]; + if (!fileOrPath) continue; + + const isString = typeof fileOrPath === 'string'; + const path = isString ? fileOrPath : fileOrPath.path; + const name = isString ? path.split('/').pop() || path : fileOrPath.name; if (onProgress) { - onProgress(i + 1, files.length, file.name); + onProgress(i + 1, files.length, name); } try { - const existingFile = this.app.vault.getFileByPath(file.path); - if (!existingFile) { - results.failed++; - results.errors.push({ file: file.path, error: 'File no longer exists' }); - continue; + let content: string; + if (isString) { + if (!(await this.app.vault.adapter.exists(path))) { + results.failed++; + results.errors.push({ file: path, error: 'File no longer exists' }); + continue; + } + content = await this.app.vault.adapter.read(path); + } else { + const existingFile = this.app.vault.getFileByPath(path); + if (!existingFile) { + results.failed++; + results.errors.push({ file: path, error: 'File no longer exists' }); + continue; + } + content = await this.app.vault.read(existingFile); } - const content = await this.app.vault.read(existingFile); - const remote = await this.gitService.getFile(file.path, this.settings.branch); + const remote = await this.gitService.getFile(path, this.settings.branch); await this.gitService.pushFile( - file.path, + path, content, this.settings.branch, - `Update ${file.name} from Obsidian`, + `Update ${name} from Obsidian`, remote.sha || undefined ); - const newRemote = await this.gitService.getFile(file.path, this.settings.branch); - this.settings.syncMetadata[file.path] = { + const newRemote = await this.gitService.getFile(path, this.settings.branch); + this.settings.syncMetadata[path] = { lastSyncedSha: newRemote.sha, lastSyncedAt: Date.now(), - lastKnownPath: file.path + lastKnownPath: path }; results.success++; } catch (e) { - console.error(`Failed to push ${file.path}:`, e); + console.error(`Failed to push ${path}:`, e); results.failed++; results.errors.push({ - file: file.path, + file: path, error: e instanceof Error ? e.message : String(e) }); } @@ -273,48 +316,64 @@ export class SyncManager { return results; } - async pullAllFiles(files: TFile[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { + async pullAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> }; const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; for (let i = 0; i < files.length; i++) { - const file = files[i]; - if (!file) continue; + const fileOrPath = files[i]; + if (!fileOrPath) continue; + + const isString = typeof fileOrPath === 'string'; + const path = isString ? fileOrPath : fileOrPath.path; + const name = isString ? path.split('/').pop() || path : fileOrPath.name; if (onProgress) { - onProgress(i + 1, files.length, file.name); + onProgress(i + 1, files.length, name); } try { - const existingFile = this.app.vault.getFileByPath(file.path); - if (!existingFile) { - results.failed++; - results.errors.push({ file: file.path, error: 'File no longer exists' }); - continue; + if (isString) { + if (!(await this.app.vault.adapter.exists(path))) { + results.failed++; + results.errors.push({ file: path, error: 'File no longer exists' }); + continue; + } + } else { + const existingFile = this.app.vault.getFileByPath(path); + if (!existingFile) { + results.failed++; + results.errors.push({ file: path, error: 'File no longer exists' }); + continue; + } } - const remote = await this.gitService.getFile(file.path, this.settings.branch); + const remote = await this.gitService.getFile(path, this.settings.branch); if (!remote.sha) { results.failed++; - results.errors.push({ file: file.path, error: 'File not found in remote' }); + results.errors.push({ file: path, error: 'File not found in remote' }); continue; } - await this.app.vault.modify(existingFile, remote.content); + if (isString) { + await this.app.vault.adapter.write(path, remote.content); + } else if (fileOrPath instanceof TFile) { + await this.app.vault.modify(fileOrPath, remote.content); + } - this.settings.syncMetadata[file.path] = { + this.settings.syncMetadata[path] = { lastSyncedSha: remote.sha, lastSyncedAt: Date.now(), - lastKnownPath: file.path + lastKnownPath: path }; results.success++; } catch (e) { - console.error(`Failed to pull ${file.path}:`, e); + console.error(`Failed to pull ${path}:`, e); results.failed++; results.errors.push({ - file: file.path, + file: path, error: e instanceof Error ? e.message : String(e) }); } diff --git a/src/main.ts b/src/main.ts index b4237d7..4cf4076 100644 --- a/src/main.ts +++ b/src/main.ts @@ -70,7 +70,7 @@ export default class GitLabFilesPush extends Plugin { this.addCommand({ id: 'push-all-files', - name: 'Push all markdown files', + name: 'Push all files', callback: () => { void this.pushAllFiles(); } @@ -78,7 +78,7 @@ export default class GitLabFilesPush extends Plugin { this.addCommand({ id: 'pull-all-files', - name: 'Pull all markdown files', + name: 'Pull all files', callback: () => { void this.pullAllFiles(); } @@ -133,7 +133,7 @@ export default class GitLabFilesPush extends Plugin { } async pushAllFiles(): Promise { - const allFiles = this.app.vault.getMarkdownFiles(); + const allFiles = this.app.vault.getFiles(); const files = this.filterFilesByVaultFolder(allFiles); const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; @@ -142,7 +142,7 @@ export default class GitLabFilesPush extends Plugin { return; } - const confirmed = await this.showConfirmDialog(`Push ${files.length} markdown file(s) to ${serviceName}?`); + const confirmed = await this.showConfirmDialog(`Push ${files.length} file(s) to ${serviceName}?`); if (!confirmed) return; const progressNotice = new Notice(`Pushing 0/${files.length} files...`, 0); @@ -165,7 +165,7 @@ export default class GitLabFilesPush extends Plugin { } async pullAllFiles(): Promise { - const allFiles = this.app.vault.getMarkdownFiles(); + const allFiles = this.app.vault.getFiles(); const files = this.filterFilesByVaultFolder(allFiles); const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; @@ -174,7 +174,7 @@ export default class GitLabFilesPush extends Plugin { return; } - const confirmed = await this.showConfirmDialog(`Pull ${files.length} markdown file(s) from ${serviceName}? This will overwrite local changes.`); + const confirmed = await this.showConfirmDialog(`Pull ${files.length} file(s) from ${serviceName}? This will overwrite local changes.`); if (!confirmed) return; const progressNotice = new Notice(`Pulling 0/${files.length} files...`, 0); diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index 01a29ac..2d891c7 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -3,4 +3,6 @@ export interface GitServiceInterface { getFile(path: string, branch: string): Promise<{ content: string; sha: string }>; pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise; testConnection(): Promise; + listFiles(branch: string, path?: string): Promise; + deleteFile(path: string, branch: string, commitMessage: string): Promise; } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 1c093d8..eb3e860 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -70,9 +70,8 @@ export class GitHubService implements GitServiceInterface { url, method: 'GET', headers: { - 'Authorization': `Bearer ${this.token}`, - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'Obsidian-GitLab-Files-Push' + 'Authorization': `token ${this.token}`, + 'Accept': 'application/vnd.github.v3+json' } }); @@ -123,10 +122,9 @@ export class GitHubService implements GitServiceInterface { url, method: 'PUT', headers: { - 'Authorization': `Bearer ${this.token}`, + 'Authorization': `token ${this.token}`, 'Accept': 'application/vnd.github.v3+json', - 'Content-Type': 'application/json', - 'User-Agent': 'Obsidian-GitLab-Files-Push' + 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); @@ -145,19 +143,105 @@ export class GitHubService implements GitServiceInterface { if (!this.repo) throw new Error('Repository is missing'); const url = `https://api.github.com/repos/${this.owner}/${this.repo}`; + + try { + const response = await this.safeRequest({ + url, + method: 'GET', + headers: { + 'Authorization': `token ${this.token}`, + 'Accept': 'application/vnd.github.v3+json' + } + }); + + if (response.status !== 200) { + const errorBody = response.text || JSON.stringify(response.json); + throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`); + } + } catch (e) { + if (e instanceof Error) { + // Provide more helpful error messages for common issues + if (e.message.includes('NAME') || e.message.includes('resolve')) { + throw new Error(`DNS resolution failed. Please check your network connection or try restarting Obsidian. Original error: ${e.message}`); + } + if (e.message.includes('CERT') || e.message.includes('certificate')) { + throw new Error(`SSL certificate error. This may be caused by network security settings. Original error: ${e.message}`); + } + } + throw e; + } + } + + async listFiles(branch: string, path: string = ''): Promise { + const searchPath = this.rootPath ? (path ? `${this.rootPath}/${path}` : this.rootPath) : path; + const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; + const response = await this.safeRequest({ url, method: 'GET', headers: { - 'Authorization': `Bearer ${this.token}`, - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'Obsidian-GitLab-Files-Push' + 'Authorization': `token ${this.token}`, + 'Accept': 'application/vnd.github.v3+json' } }); if (response.status !== 200) { const errorBody = response.text || JSON.stringify(response.json); - throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`); + throw new Error(`Failed to list files: ${response.status} ${url}. Response: ${errorBody}`); + } + + interface TreeItem { + path: string; + type: string; + } + + interface TreeResponse { + tree: TreeItem[]; + } + + const data = response.json as TreeResponse; + const allFiles = data.tree + .filter(item => item.type === 'blob' && item.path.endsWith('.md')) + .map(item => item.path); + + // Filter by rootPath if set + if (this.rootPath) { + const prefix = this.rootPath + '/'; + return allFiles + .filter(file => file.startsWith(prefix)) + .map(file => file.substring(prefix.length)); + } + + return allFiles; + } + + async deleteFile(path: string, branch: string, commitMessage: string): Promise { + const url = this.getApiUrl(path); + + // Get current file SHA + const fileInfo = await this.getFile(path, branch); + if (!fileInfo.sha) { + throw new Error(`File not found: ${path}`); + } + + const response = await this.safeRequest({ + url, + method: 'DELETE', + headers: { + 'Authorization': `token ${this.token}`, + 'Accept': 'application/vnd.github.v3+json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: commitMessage, + sha: fileInfo.sha, + branch + }) + }); + + if (response.status !== 200 && response.status !== 204) { + const errorBody = response.text || JSON.stringify(response.json); + throw new Error(`Failed to delete file: ${response.status} DELETE ${url}. Response: ${errorBody}`); } } } diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index f36eee7..5290b9f 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -158,4 +158,66 @@ export class GitLabService implements GitServiceInterface { throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`); } } + + async listFiles(branch: string, path: string = ''): Promise { + const encodedProjectId = encodeURIComponent(this.projectId); + const searchPath = this.rootPath || path || ''; + const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100${searchPath ? `&path=${encodeURIComponent(searchPath)}` : ''}`; + + const response = await this.safeRequest({ + url, + method: 'GET', + headers: { + 'PRIVATE-TOKEN': this.token + } + }); + + if (response.status !== 200) { + const errorBody = response.text || JSON.stringify(response.json); + throw new Error(`Failed to list files: ${response.status} ${url}. Response: ${errorBody}`); + } + + interface TreeItem { + path: string; + type: string; + name: string; + } + + const data = response.json as TreeItem[]; + const allFiles = data + .filter(item => item.type === 'blob' && item.path.endsWith('.md')) + .map(item => item.path); + + // Filter by rootPath if set + if (this.rootPath) { + const prefix = this.rootPath + '/'; + return allFiles + .filter(file => file.startsWith(prefix)) + .map(file => file.substring(prefix.length)); + } + + return allFiles; + } + + async deleteFile(path: string, branch: string, commitMessage: string): Promise { + const url = this.getApiUrl(path); + + const response = await this.safeRequest({ + url, + method: 'DELETE', + headers: { + 'PRIVATE-TOKEN': this.token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + branch, + commit_message: commitMessage + }) + }); + + if (response.status !== 200 && response.status !== 204) { + const errorBody = response.text || JSON.stringify(response.json); + throw new Error(`Failed to delete file: ${response.status} DELETE ${url}. Response: ${errorBody}`); + } + } } diff --git a/src/ui/SyncConflictModal.ts b/src/ui/SyncConflictModal.ts index b213023..c717d6b 100644 --- a/src/ui/SyncConflictModal.ts +++ b/src/ui/SyncConflictModal.ts @@ -1,14 +1,14 @@ import { App, Modal, Setting, TFile } from 'obsidian'; export class SyncConflictModal extends Modal { - private file: TFile; + private fileName: string; private localContent: string; private remoteContent: string; private onChoose: (choice: 'local' | 'remote') => void; - constructor(app: App, file: TFile, local: string, remote: string, onChoose: (choice: 'local' | 'remote') => void) { + constructor(app: App, fileName: string, local: string, remote: string, onChoose: (choice: 'local' | 'remote') => void) { super(app); - this.file = file; + this.fileName = fileName; this.localContent = local; this.remoteContent = remote; this.onChoose = onChoose; @@ -18,7 +18,7 @@ export class SyncConflictModal extends Modal { const { contentEl } = this; contentEl.addClass('sync-conflict-modal'); - contentEl.createEl('h2', { text: `Conflict in ${this.file.name}` }); + contentEl.createEl('h2', { text: `Conflict in ${this.fileName}` }); contentEl.createEl('p', { text: 'The remote file has different content. Review the differences and choose which version to keep.', cls: 'conflict-description' diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 7fc1479..5a6e463 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -4,7 +4,8 @@ import GitLabFilesPush from '../main'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; interface FileStatus { - file: TFile; + file?: TFile; + path: string; status: 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'checking'; localContent?: string; remoteContent?: string; @@ -16,6 +17,10 @@ export class SyncStatusView extends ItemView { plugin: GitLabFilesPush; private fileStatuses: Map = new Map(); private isRefreshing = false; + private statusFilter: 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only' = 'all'; + private selectedFiles: Set = new Set(); + private lastSyncTime: number = 0; + private previousFilter: typeof this.statusFilter = 'all'; constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush) { super(leaf); @@ -58,23 +63,108 @@ export class SyncStatusView extends ItemView { if (this.plugin.settings.vaultFolder) { serviceInfo.createEl('div', { text: `Vault Folder: ${this.plugin.settings.vaultFolder}` }); } + if (this.lastSyncTime > 0) { + const lastSyncDate = new Date(this.lastSyncTime); + const timeStr = lastSyncDate.toLocaleString(); + serviceInfo.createEl('div', { text: `Last sync: ${timeStr}` }); + } const buttonContainer = container.createDiv({ cls: 'sync-status-buttons' }); - const refreshBtn = buttonContainer.createEl('button', { text: 'Refresh status' }); + + // Refresh button + const refreshBtn = buttonContainer.createEl('button', { text: 'Refresh status', cls: 'refresh-btn' }); refreshBtn.addEventListener('click', () => { void this.refreshAllStatuses(); }); - const pushAllBtn = buttonContainer.createEl('button', { text: 'Push all modified', cls: 'push-all-btn' }); - pushAllBtn.addEventListener('click', () => { - void this.pushAllModified(); + // Selection buttons container + const selectionContainer = container.createDiv({ cls: 'sync-selection-buttons' }); + + const selectAllBtn = selectionContainer.createEl('button', { text: 'Select all' }); + selectAllBtn.addEventListener('click', () => { + const visibleStatuses = this.statusFilter === 'all' + ? Array.from(this.fileStatuses.values()) + : Array.from(this.fileStatuses.values()).filter(s => s.status === this.statusFilter); + + for (const status of visibleStatuses) { + this.selectedFiles.add(status.path); + } + this.renderView(); }); - const pullAllBtn = buttonContainer.createEl('button', { text: 'Pull all modified', cls: 'pull-all-btn' }); - pullAllBtn.addEventListener('click', () => { - void this.pullAllModified(); + const deselectAllBtn = selectionContainer.createEl('button', { text: 'Deselect all' }); + deselectAllBtn.addEventListener('click', () => { + this.selectedFiles.clear(); + this.renderView(); }); + // Repository operation buttons container + const repoContainer = container.createDiv({ cls: 'sync-repo-buttons' }); + + // Calculate counts for each operation + const selectedStatuses = Array.from(this.selectedFiles) + .map(path => this.fileStatuses.get(path)) + .filter(s => s) as FileStatus[]; + + const canPush = selectedStatuses.filter(s => s.file && (s.status === 'modified' || s.status === 'unsynced')).length; + const canPull = selectedStatuses.filter(s => s.status === 'modified' || s.status === 'remote-only').length; + const canDelete = selectedStatuses.filter(s => s.file || s.status === 'remote-only').length; + + const pushSelectedBtn = repoContainer.createEl('button', { + text: `Push selected (${canPush})`, + cls: 'push-all-btn' + }); + pushSelectedBtn.disabled = canPush === 0; + pushSelectedBtn.addEventListener('click', () => { + void this.pushSelected(); + }); + + const pullSelectedBtn = repoContainer.createEl('button', { + text: `Pull selected (${canPull})`, + cls: 'pull-all-btn' + }); + pullSelectedBtn.disabled = canPull === 0; + pullSelectedBtn.addEventListener('click', () => { + void this.pullSelected(); + }); + + const deleteSelectedBtn = repoContainer.createEl('button', { + text: `Delete selected (${canDelete})`, + cls: 'delete-btn' + }); + deleteSelectedBtn.disabled = canDelete === 0; + deleteSelectedBtn.addEventListener('click', () => { + void this.deleteSelected(); + }); + + // Filter buttons + const filterContainer = container.createDiv({ cls: 'sync-status-filters' }); + filterContainer.createEl('span', { text: 'Show: ' }); + + type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; + const filters: Array<{ value: FilterValue; label: string }> = [ + { value: 'all', label: 'All' }, + { value: 'synced', label: 'Synced' }, + { value: 'modified', label: 'Modified' }, + { value: 'unsynced', label: 'Not in remote' }, + { value: 'remote-only', label: 'Remote only' } + ]; + + for (const filter of filters) { + const btn = filterContainer.createEl('button', { + text: filter.label, + cls: this.statusFilter === filter.value ? 'filter-active' : '' + }); + btn.addEventListener('click', () => { + // Clear selections when switching filter + if (this.statusFilter !== filter.value) { + this.selectedFiles.clear(); + } + this.statusFilter = filter.value; + this.renderView(); + }); + } + const statusContainer = container.createDiv({ cls: 'sync-status-list' }); if (this.fileStatuses.size === 0) { @@ -88,16 +178,31 @@ export class SyncStatusView extends ItemView { } private renderFileStatuses(container: HTMLElement): void { - const statuses = Array.from(this.fileStatuses.values()); + const allStatuses = Array.from(this.fileStatuses.values()); + + // Apply filter + const statuses = this.statusFilter === 'all' + ? allStatuses + : allStatuses.filter(s => s.status === this.statusFilter); const summary = container.createDiv({ cls: 'sync-status-summary' }); - const synced = statuses.filter(s => s.status === 'synced').length; - const modified = statuses.filter(s => s.status === 'modified').length; - const unsynced = statuses.filter(s => s.status === 'unsynced').length; + const synced = allStatuses.filter(s => s.status === 'synced').length; + const modified = allStatuses.filter(s => s.status === 'modified').length; + const unsynced = allStatuses.filter(s => s.status === 'unsynced').length; + const remoteOnly = allStatuses.filter(s => s.status === 'remote-only').length; summary.createEl('div', { text: `✓ Synced: ${synced}` }); summary.createEl('div', { text: `⚠ Modified: ${modified}` }); summary.createEl('div', { text: `✗ Unsynced: ${unsynced}` }); + summary.createEl('div', { text: `↓ Remote only: ${remoteOnly}` }); + + if (statuses.length === 0) { + container.createEl('div', { + text: this.statusFilter === 'all' ? 'No files found' : `No ${this.statusFilter} files`, + cls: 'sync-status-empty' + }); + return; + } for (const fileStatus of statuses) { this.renderFileStatus(container, fileStatus); @@ -109,6 +214,18 @@ export class SyncStatusView extends ItemView { const headerEl = fileEl.createDiv({ cls: 'sync-status-file-header' }); + // Add checkbox + const checkbox = headerEl.createEl('input', { type: 'checkbox' }); + checkbox.checked = this.selectedFiles.has(fileStatus.path); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.selectedFiles.add(fileStatus.path); + } else { + this.selectedFiles.delete(fileStatus.path); + } + this.renderView(); + }); + let icon = '○'; let statusText = ''; let statusClass = ''; @@ -129,6 +246,11 @@ export class SyncStatusView extends ItemView { statusText = 'Not in remote'; statusClass = 'status-unsynced'; break; + case 'remote-only': + icon = '↓'; + statusText = 'Remote only'; + statusClass = 'status-remote-only'; + break; case 'checking': icon = '⟳'; statusText = 'Checking...'; @@ -137,9 +259,122 @@ export class SyncStatusView extends ItemView { } headerEl.createSpan({ text: `${icon} `, cls: `status-icon ${statusClass}` }); - headerEl.createSpan({ text: fileStatus.file.path, cls: 'file-path' }); + headerEl.createSpan({ text: fileStatus.path, cls: 'file-path' }); headerEl.createSpan({ text: ` (${statusText})`, cls: `status-text ${statusClass}` }); + if (fileStatus.status === 'unsynced' && fileStatus.file) { + const actionsEl = fileEl.createDiv({ cls: 'sync-status-actions' }); + const pushBtn = actionsEl.createEl('button', { text: 'Push to remote' }); + const removeBtn = actionsEl.createEl('button', { text: 'Remove local file', cls: 'remove-btn' }); + + pushBtn.addEventListener('click', async () => { + try { + // Mark as checking + fileStatus.status = 'checking'; + this.renderView(); + + await this.plugin.sync.pushFile(fileStatus.file || fileStatus.path); + + // Wait a bit for the remote to update + await new Promise(resolve => setTimeout(resolve, 500)); + + await this.refreshFileStatus(fileStatus.file || fileStatus.path); + this.renderView(); + } catch (e) { + console.error(e); + new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`); + // Refresh to show current state + await this.refreshFileStatus(fileStatus.file || fileStatus.path); + this.renderView(); + } + }); + + removeBtn.addEventListener('click', async () => { + const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`); + if (confirmed) { + try { + if (fileStatus.file) { + await this.app.vault.delete(fileStatus.file); + } else { + await this.app.vault.adapter.remove(fileStatus.path); + } + new Notice(`Deleted ${fileStatus.path}`); + this.fileStatuses.delete(fileStatus.path); + this.renderView(); + } catch (e) { + console.error(e); + new Notice(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`); + } + } + }); + } + + if (fileStatus.status === 'remote-only') { + const actionsEl = fileEl.createDiv({ cls: 'sync-status-actions' }); + const pullBtn = actionsEl.createEl('button', { text: 'Pull from remote' }); + + pullBtn.addEventListener('click', async () => { + try { + // Mark as checking + fileStatus.status = 'checking'; + this.renderView(); + + console.log('Pulling remote-only file:', fileStatus.path); + const remote = await this.plugin.gitService.getFile(fileStatus.path, this.plugin.settings.branch); + console.log('Got remote file, content length:', remote.content.length, 'sha:', remote.sha); + + if (remote.content) { + console.log('Creating file at path:', fileStatus.path); + + // Ensure parent directories exist (recursively) + const pathParts = fileStatus.path.split('/'); + if (pathParts.length > 1) { + let currentPath = ''; + for (let i = 0; i < pathParts.length - 1; i++) { + currentPath += (i > 0 ? '/' : '') + pathParts[i]; + const dir = this.app.vault.getAbstractFileByPath(currentPath); + if (!dir) { + console.log('Creating directory:', currentPath); + try { + await this.app.vault.createFolder(currentPath); + } catch (e) { + // Folder might already exist, ignore error + console.log('Folder creation error (might already exist):', e); + } + } + } + } + + await this.app.vault.create(fileStatus.path, remote.content); + console.log('File created successfully'); + + // Update sync metadata + this.plugin.settings.syncMetadata[fileStatus.path] = { + lastSyncedSha: remote.sha, + lastSyncedAt: Date.now(), + lastKnownPath: fileStatus.path + }; + await this.plugin.saveSettings(); + console.log('Metadata saved'); + + new Notice(`Pulled ${fileStatus.path}`); + + // Wait for file to be created and vault to update + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Force refresh all statuses to pick up the new file + console.log('Refreshing all statuses after pull'); + await this.refreshAllStatuses(); + } + } catch (e) { + console.error(e); + new Notice(`Failed to pull: ${e instanceof Error ? e.message : String(e)}`); + // Refresh to show current state + await this.refreshAllStatuses(); + } + }); + } + if (fileStatus.status === 'modified' && fileStatus.diff) { const diffToggle = headerEl.createEl('button', { text: 'Show diff', @@ -166,14 +401,48 @@ export class SyncStatusView extends ItemView { const pushBtn = actionsEl.createEl('button', { text: 'Push' }); const pullBtn = actionsEl.createEl('button', { text: 'Pull' }); - pushBtn.addEventListener('click', () => { - void this.plugin.sync.pushFile(fileStatus.file); - setTimeout(() => void this.refreshFileStatus(fileStatus.file), 1000); + pushBtn.addEventListener('click', async () => { + try { + // Mark as checking + fileStatus.status = 'checking'; + this.renderView(); + + await this.plugin.sync.pushFile(fileStatus.file || fileStatus.path); + + // Wait a bit for the remote to update + await new Promise(resolve => setTimeout(resolve, 500)); + + await this.refreshFileStatus(fileStatus.file || fileStatus.path); + this.renderView(); + } catch (e) { + console.error(e); + new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`); + // Refresh to show current state + await this.refreshFileStatus(fileStatus.file || fileStatus.path); + this.renderView(); + } }); - pullBtn.addEventListener('click', () => { - void this.plugin.sync.pullFile(fileStatus.file); - setTimeout(() => void this.refreshFileStatus(fileStatus.file), 1000); + pullBtn.addEventListener('click', async () => { + try { + // Mark as checking + fileStatus.status = 'checking'; + this.renderView(); + + await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path); + + // Wait a bit for the file to be written + await new Promise(resolve => setTimeout(resolve, 500)); + + await this.refreshFileStatus(fileStatus.file || fileStatus.path); + this.renderView(); + } catch (e) { + console.error(e); + new Notice(`Pull failed: ${e instanceof Error ? e.message : String(e)}`); + // Refresh to show current state + await this.refreshFileStatus(fileStatus.file || fileStatus.path); + this.renderView(); + } }); } } @@ -192,29 +461,98 @@ export class SyncStatusView extends ItemView { const statusContainer = container.querySelector('.sync-status-list') as HTMLElement; if (statusContainer) { statusContainer.empty(); - statusContainer.createEl('div', { text: 'Checking files...', cls: 'sync-status-loading' }); + + // Add progress bar + const progressContainer = statusContainer.createDiv({ cls: 'sync-progress-container' }); + progressContainer.createEl('div', { text: 'Checking files...', cls: 'sync-progress-text' }); + const progressBar = progressContainer.createDiv({ cls: 'sync-progress-bar' }); + const progressFill = progressBar.createDiv({ cls: 'sync-progress-fill' }); + progressFill.style.width = '0%'; } } try { - const allFiles = this.app.vault.getMarkdownFiles(); + const allFiles = this.app.vault.getFiles(); const files = this.plugin.filterFilesByVaultFolder(allFiles); + // Get remote files + const remoteFiles = await this.plugin.gitService.listFiles(this.plugin.settings.branch); + const localFilePaths = new Set(files.map(f => f.path)); + // Use ALL local files (not just vaultFolder-filtered) for remote-only detection, + // so files like .claude/skills/*.md that live outside vaultFolder are not + // incorrectly labelled "remote only" when they actually exist locally. + const allLocalFileMap = new Map(allFiles.map(f => [f.path, f])); + + // Add local files to status for (const file of files) { this.fileStatuses.set(file.path, { file, + path: file.path, status: 'checking' }); } - this.renderView(); + // Add remote-only files to status, or queue cross-vaultFolder files for checking + const extraFilesToCheck: Array = []; + for (const remotePath of remoteFiles) { + if (!localFilePaths.has(remotePath)) { + let localFile = allLocalFileMap.get(remotePath); + if (!localFile) { + const abstractFile = this.app.vault.getAbstractFileByPath(remotePath); + if (abstractFile instanceof TFile) { + localFile = abstractFile; + } + } - for (const file of files) { - await this.refreshFileStatus(file); + if (localFile) { + // File exists locally but outside vaultFolder – check its real status + this.fileStatuses.set(remotePath, { + file: localFile, + path: remotePath, + status: 'checking' + }); + extraFilesToCheck.push(localFile); + } else if (await this.app.vault.adapter.exists(remotePath)) { + this.fileStatuses.set(remotePath, { + path: remotePath, + status: 'checking' + }); + extraFilesToCheck.push(remotePath); + } else { + this.fileStatuses.set(remotePath, { + path: remotePath, + status: 'remote-only' + }); + } + } } this.renderView(); - new Notice(`Checked ${files.length} files`); + + // Check status for local files with progress + const filesToCheck: Array = [...files, ...extraFilesToCheck]; + const totalFiles = filesToCheck.length; + let checkedFiles = 0; + + for (const file of filesToCheck) { + await this.refreshFileStatus(file); + checkedFiles++; + + // Update progress bar + if (container) { + const progressFill = container.querySelector('.sync-progress-fill') as HTMLElement; + const progressText = container.querySelector('.sync-progress-text') as HTMLElement; + if (progressFill && progressText) { + const percentage = Math.round((checkedFiles / totalFiles) * 100); + progressFill.style.width = `${percentage}%`; + progressText.textContent = `Checking files... ${checkedFiles}/${totalFiles} (${percentage}%)`; + } + } + } + + this.lastSyncTime = Date.now(); + this.renderView(); + new Notice(`Checked ${files.length} local files and ${remoteFiles.length} remote files`); } catch (e) { console.error(e); new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`); @@ -223,10 +561,17 @@ export class SyncStatusView extends ItemView { } } - private async refreshFileStatus(file: TFile): Promise { + private async refreshFileStatus(fileOrPath: TFile | string): Promise { try { - const localContent = await this.app.vault.read(file); - const remote = await this.plugin.gitService.getFile(file.path, this.plugin.settings.branch); + const isString = typeof fileOrPath === 'string'; + const path = isString ? fileOrPath : fileOrPath.path; + const file = isString ? undefined : fileOrPath; + + const localContent = isString + ? await this.app.vault.adapter.read(path) + : await this.app.vault.read(file as TFile); + + const remote = await this.plugin.gitService.getFile(path, this.plugin.settings.branch); let status: FileStatus['status']; let diff: string | undefined; @@ -240,8 +585,9 @@ export class SyncStatusView extends ItemView { diff = this.generateDiff(remote.content, localContent); } - this.fileStatuses.set(file.path, { + this.fileStatuses.set(path, { file, + path: path, status, localContent, remoteContent: remote.content, @@ -250,9 +596,11 @@ export class SyncStatusView extends ItemView { }); } catch (e) { - console.error(`Error checking ${file.path}:`, e); - this.fileStatuses.set(file.path, { - file, + const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + console.error(`Error checking ${path}:`, e); + this.fileStatuses.set(path, { + file: typeof fileOrPath === 'string' ? undefined : fileOrPath, + path: path, status: 'unsynced' }); } @@ -289,7 +637,7 @@ export class SyncStatusView extends ItemView { async pushAllModified(): Promise { const modifiedFiles = Array.from(this.fileStatuses.values()) .filter(s => s.status === 'modified' || s.status === 'unsynced') - .map(s => s.file); + .map(s => s.file || s.path); if (modifiedFiles.length === 0) { new Notice('No modified files to push'); @@ -313,6 +661,7 @@ export class SyncStatusView extends ItemView { console.error('Push errors:', results.errors); } + new Notice(`Push completed. Refreshing status...`); await this.refreshAllStatuses(); } catch (e) { progressNotice.hide(); @@ -324,7 +673,7 @@ export class SyncStatusView extends ItemView { async pullAllModified(): Promise { const modifiedFiles = Array.from(this.fileStatuses.values()) .filter(s => s.status === 'modified') - .map(s => s.file); + .map(s => s.file || s.path); if (modifiedFiles.length === 0) { new Notice('No modified files to pull'); @@ -348,6 +697,10 @@ export class SyncStatusView extends ItemView { console.error('Pull errors:', results.errors); } + // Wait a bit for files to be written + await new Promise(resolve => setTimeout(resolve, 1000)); + + new Notice(`Pull completed. Refreshing status...`); await this.refreshAllStatuses(); } catch (e) { progressNotice.hide(); @@ -356,6 +709,240 @@ export class SyncStatusView extends ItemView { } } + async pushSelected(): Promise { + if (this.selectedFiles.size === 0) { + new Notice('No files selected'); + return; + } + + const selectedStatuses = Array.from(this.selectedFiles) + .map(path => this.fileStatuses.get(path)) + .filter(s => s && (s.status === 'modified' || s.status === 'unsynced')) as FileStatus[]; + + if (selectedStatuses.length === 0) { + new Notice('No files can be pushed. Only modified or unsynced files can be pushed.'); + return; + } + + const files = selectedStatuses.map(s => s.file || s.path); + + const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + const confirmed = await this.showConfirmDialog(`Push ${files.length} selected file(s) to ${serviceName}?`); + if (!confirmed) return; + + const progressNotice = new Notice(`Pushing 0/${files.length} files...`, 0); + + try { + const results = await this.plugin.sync.pushAllFiles(files, (current, total, fileName) => { + progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`); + }); + + progressNotice.hide(); + + if (results.errors.length > 0) { + console.error('Push errors:', results.errors); + } + + this.selectedFiles.clear(); + + // Wait a bit for remote to update + await new Promise(resolve => setTimeout(resolve, 1000)); + + new Notice(`Push completed. Refreshing status...`); + await this.refreshAllStatuses(); + } catch (e) { + progressNotice.hide(); + console.error(e); + new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + + async pullSelected(): Promise { + if (this.selectedFiles.size === 0) { + new Notice('No files selected'); + return; + } + + const selectedStatuses = Array.from(this.selectedFiles) + .map(path => this.fileStatuses.get(path)) + .filter(s => s && (s.status === 'modified' || s.status === 'remote-only')) as FileStatus[]; + + if (selectedStatuses.length === 0) { + new Notice('No files can be pulled. Only modified or remote-only files can be pulled.'); + return; + } + + const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + const confirmed = await this.showConfirmDialog(`Pull ${selectedStatuses.length} selected file(s) from ${serviceName}? This will overwrite local changes.`); + if (!confirmed) return; + + const progressNotice = new Notice(`Pulling 0/${selectedStatuses.length} files...`, 0); + + try { + let current = 0; + const errors: string[] = []; + + for (const status of selectedStatuses) { + current++; + progressNotice.setMessage(`Pulling ${current}/${selectedStatuses.length}: ${status.path}`); + + try { + if (status.status !== 'remote-only') { + // Existing file - use sync manager + await this.plugin.sync.pullFile(status.file || status.path); + } else { + // Remote-only file - create it + const remote = await this.plugin.gitService.getFile(status.path, this.plugin.settings.branch); + if (remote.content) { + // Ensure parent directories exist (recursively) + const pathParts = status.path.split('/'); + if (pathParts.length > 1) { + let currentPath = ''; + for (let i = 0; i < pathParts.length - 1; i++) { + currentPath += (i > 0 ? '/' : '') + pathParts[i]; + const dir = this.app.vault.getAbstractFileByPath(currentPath); + if (!dir) { + try { + await this.app.vault.createFolder(currentPath); + } catch (e) { + // Folder might already exist, ignore error + } + } + } + } + + await this.app.vault.create(status.path, remote.content); + + // Update sync metadata + this.plugin.settings.syncMetadata[status.path] = { + lastSyncedSha: remote.sha, + lastSyncedAt: Date.now(), + lastKnownPath: status.path + }; + } + } + } catch (e) { + console.error(`Error pulling ${status.path}:`, e); + errors.push(status.path); + } + } + + progressNotice.hide(); + + // Save settings if any metadata was updated + await this.plugin.saveSettings(); + + if (errors.length > 0) { + new Notice(`Pulled ${selectedStatuses.length - errors.length}/${selectedStatuses.length} files. ${errors.length} failed.`); + } else { + new Notice(`Successfully pulled ${selectedStatuses.length} files`); + } + + this.selectedFiles.clear(); + + // Wait a bit for files to be written + await new Promise(resolve => setTimeout(resolve, 1000)); + + new Notice(`Pull completed. Refreshing status...`); + await this.refreshAllStatuses(); + } catch (e) { + progressNotice.hide(); + console.error(e); + new Notice(`Pull failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + + async deleteSelected(): Promise { + if (this.selectedFiles.size === 0) { + new Notice('No files selected'); + return; + } + + const selectedStatuses = Array.from(this.selectedFiles) + .map(path => this.fileStatuses.get(path)) + .filter(s => s) as FileStatus[]; + + const localFiles = selectedStatuses.filter(s => s.status !== 'remote-only'); + const remoteOnlyFiles = selectedStatuses.filter(s => s.status === 'remote-only'); + + if (localFiles.length === 0 && remoteOnlyFiles.length === 0) { + new Notice('No files selected to delete'); + return; + } + + let confirmMessage = ''; + if (localFiles.length > 0 && remoteOnlyFiles.length > 0) { + confirmMessage = `Delete ${localFiles.length} local file(s) and ${remoteOnlyFiles.length} remote file(s)? This cannot be undone.`; + } else if (localFiles.length > 0) { + confirmMessage = `Delete ${localFiles.length} local file(s)? This cannot be undone.`; + } else { + confirmMessage = `Delete ${remoteOnlyFiles.length} remote file(s)? This cannot be undone.`; + } + + const confirmed = await this.showConfirmDialog(confirmMessage); + if (!confirmed) return; + + const totalFiles = localFiles.length + remoteOnlyFiles.length; + const progressNotice = new Notice(`Deleting 0/${totalFiles} files...`, 0); + + try { + let current = 0; + const errors: string[] = []; + + // Delete local files + for (const status of localFiles) { + current++; + progressNotice.setMessage(`Deleting local ${current}/${totalFiles}: ${status.path}`); + + try { + if (status.file) { + await this.app.vault.delete(status.file); + } else { + await this.app.vault.adapter.remove(status.path); + } + this.fileStatuses.delete(status.path); + this.selectedFiles.delete(status.path); + } catch (e) { + console.error(`Error deleting local ${status.path}:`, e); + errors.push(status.path); + } + } + + // Delete remote files + for (const status of remoteOnlyFiles) { + current++; + progressNotice.setMessage(`Deleting remote ${current}/${totalFiles}: ${status.path}`); + + try { + await this.plugin.gitService.deleteFile( + status.path, + this.plugin.settings.branch, + `Delete ${status.path}` + ); + this.fileStatuses.delete(status.path); + this.selectedFiles.delete(status.path); + } catch (e) { + console.error(`Error deleting remote ${status.path}:`, e); + errors.push(status.path); + } + } + + progressNotice.hide(); + + if (errors.length > 0) { + new Notice(`Deleted ${totalFiles - errors.length}/${totalFiles} files. ${errors.length} failed.`); + } else { + new Notice(`Successfully deleted ${totalFiles} files`); + } + + this.renderView(); + } catch (e) { + progressNotice.hide(); + console.error(e); + new Notice(`Delete failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + async onClose(): Promise { // Cleanup } diff --git a/styles.css b/styles.css index 9486cc9..e4f93ce 100644 --- a/styles.css +++ b/styles.css @@ -40,38 +40,109 @@ If your plugin does not need CSS, delete this file. } .sync-status-buttons { - margin-bottom: 15px; + margin-bottom: 10px; display: flex; gap: 10px; flex-wrap: wrap; } -.sync-status-buttons button { +.sync-selection-buttons { + margin-bottom: 10px; + display: flex; + gap: 10px; + flex-wrap: wrap; + padding: 10px; + background: var(--background-secondary); + border-radius: 5px; +} + +.sync-repo-buttons { + margin-bottom: 15px; + display: flex; + gap: 10px; + flex-wrap: wrap; + padding: 10px; + background: var(--background-secondary); + border-radius: 5px; +} + +.sync-status-buttons button, +.sync-selection-buttons button, +.sync-repo-buttons button { padding: 8px 16px; background: var(--interactive-accent); color: var(--text-on-accent); border: none; border-radius: 5px; cursor: pointer; + font-size: 0.9em; + white-space: nowrap; } -.sync-status-buttons button:hover { +.sync-status-buttons .refresh-btn { + background: var(--interactive-accent); + font-weight: 500; +} + +/* Mobile optimization */ +@media (max-width: 768px) { + .sync-status-buttons, + .sync-selection-buttons, + .sync-repo-buttons { + gap: 6px; + } + + .sync-status-buttons button, + .sync-selection-buttons button, + .sync-repo-buttons button { + padding: 6px 10px; + font-size: 0.85em; + flex: 1 1 auto; + min-width: 0; + } +} + +.sync-status-buttons button:hover, +.sync-selection-buttons button:hover, +.sync-repo-buttons button:hover { background: var(--interactive-accent-hover); } -.sync-status-buttons .push-all-btn { +.sync-status-buttons button:disabled, +.sync-selection-buttons button:disabled, +.sync-repo-buttons button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sync-status-buttons button:disabled:hover, +.sync-selection-buttons button:disabled:hover, +.sync-repo-buttons button:disabled:hover { + opacity: 0.5; +} + +.sync-repo-buttons .push-all-btn { background: var(--color-green); } -.sync-status-buttons .push-all-btn:hover { +.sync-repo-buttons .push-all-btn:hover { opacity: 0.8; } -.sync-status-buttons .pull-all-btn { +.sync-repo-buttons .pull-all-btn { background: var(--color-blue); } -.sync-status-buttons .pull-all-btn:hover { +.sync-repo-buttons .pull-all-btn:hover { + opacity: 0.8; +} + +.sync-repo-buttons .delete-btn { + background: var(--color-red); + color: white; +} + +.sync-repo-buttons .delete-btn:hover { opacity: 0.8; } @@ -83,6 +154,19 @@ If your plugin does not need CSS, delete this file. background: var(--background-secondary); border-radius: 5px; font-weight: 500; + flex-wrap: wrap; +} + +/* Mobile optimization */ +@media (max-width: 768px) { + .sync-status-summary { + gap: 10px; + font-size: 0.85em; + } + + .sync-status-summary div { + flex: 1 1 45%; + } } .sync-status-empty, @@ -92,6 +176,32 @@ If your plugin does not need CSS, delete this file. color: var(--text-muted); } +.sync-progress-container { + padding: 20px; + text-align: center; +} + +.sync-progress-text { + margin-bottom: 10px; + color: var(--text-muted); + font-size: 0.9em; +} + +.sync-progress-bar { + width: 100%; + height: 8px; + background: var(--background-secondary); + border-radius: 4px; + overflow: hidden; +} + +.sync-progress-fill { + height: 100%; + background: var(--interactive-accent); + transition: width 0.3s ease; + border-radius: 4px; +} + .sync-status-file { margin-bottom: 10px; padding: 10px; @@ -105,11 +215,43 @@ If your plugin does not need CSS, delete this file. align-items: center; gap: 5px; margin-bottom: 5px; + flex-wrap: wrap; } .status-icon { font-weight: bold; font-size: 1.1em; + flex-shrink: 0; +} + +/* Mobile optimization for file items */ +@media (max-width: 768px) { + .sync-status-file-header { + gap: 3px; + } + + .file-path { + font-size: 0.8em !important; + word-break: break-all; + } + + .status-text { + font-size: 0.75em !important; + } + + .diff-toggle { + margin-left: 0 !important; + margin-top: 5px; + width: 100%; + } + + .sync-status-actions { + flex-direction: column; + } + + .sync-status-actions button { + width: 100%; + } } .status-synced { @@ -124,10 +266,76 @@ If your plugin does not need CSS, delete this file. color: var(--text-error); } +.status-remote-only { + color: var(--color-cyan); +} + .status-checking { color: var(--text-muted); } +.sync-status-filters { + margin-bottom: 15px; + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.sync-status-filters button { + padding: 6px 12px; + font-size: 0.9em; + background: var(--background-secondary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + cursor: pointer; +} + +/* Mobile optimization */ +@media (max-width: 768px) { + .sync-status-filters { + gap: 5px; + } + + .sync-status-filters button { + padding: 5px 8px; + font-size: 0.8em; + flex: 1 1 auto; + } + + .sync-status-filters span { + width: 100%; + font-size: 0.85em; + margin-bottom: 5px; + } +} + +.sync-status-filters button:hover { + background: var(--background-modifier-hover); +} + +.sync-status-filters button.filter-active { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} + +.sync-status-file-header input[type="checkbox"] { + margin-right: 8px; + cursor: pointer; +} + +.sync-status-actions .remove-btn { + background: var(--color-red); + color: white; + border-color: var(--color-red); +} + +.sync-status-actions .remove-btn:hover { + opacity: 0.8; +} + .file-path { flex: 1; font-family: var(--font-monospace);