mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
Fix syncing of hidden files inside unindexed directories
This commit is contained in:
parent
cb1539bd46
commit
5d9cd3345d
10 changed files with 1131 additions and 129 deletions
|
|
@ -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)
|
||||
});
|
||||
}
|
||||
|
|
|
|||
12
src/main.ts
12
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<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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
222
styles.css
222
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue