Fix syncing of hidden files inside unindexed directories

This commit is contained in:
ClaudiaFang 2026-04-24 12:32:22 +00:00
parent cb1539bd46
commit 5d9cd3345d
10 changed files with 1131 additions and 129 deletions

View file

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

View file

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

View file

@ -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<string>;
testConnection(): Promise<void>;
listFiles(branch: string, path?: string): Promise<string[]>;
deleteFile(path: string, branch: string, commitMessage: string): Promise<void>;
}

View file

@ -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<string[]> {
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<void> {
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}`);
}
}
}

View file

@ -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<string[]> {
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<void> {
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}`);
}
}
}

View file

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

View file

@ -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<string, FileStatus> = new Map();
private isRefreshing = false;
private statusFilter: 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only' = 'all';
private selectedFiles: Set<string> = 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<string, TFile>(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<TFile | string> = [];
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<TFile | string> = [...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<void> {
private async refreshFileStatus(fileOrPath: TFile | string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// Cleanup
}

View file

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