mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
feat: resize conflict modal, add connection status badge, and local ignore patterns
- Conflict modal (#42): resize to min(1100px,92vw) x min(85vh,800px) flex layout, remove the 280px content height cap, and add a Diff/Local/Remote tab switcher on narrow screens (defaults to Diff). - Settings connection status (#41): show a persistent Connected/Not connected/Checking badge in the settings tab, auto-tested on open and after an 800ms debounce on token/branch/URL/owner/repo edits, updated in place (no full re-render, so typing focus is preserved). - Local ignore patterns (#40): new "Ignore patterns" setting (.gitignore- style, multi-line) applied in GitignoreManager.isIgnored() in addition to the repo's own .gitignore, covering push/pull/refresh uniformly. Closes #42, #41, #40. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYCTyZw7gUmJ7oh1VTmAqh
This commit is contained in:
parent
ef238cea59
commit
28f4f8efd0
12 changed files with 624 additions and 23 deletions
|
|
@ -10,16 +10,21 @@ export class GitignoreManager {
|
||||||
|
|
||||||
private readonly rootPath: string;
|
private readonly rootPath: string;
|
||||||
private readonly vaultFolder: string;
|
private readonly vaultFolder: string;
|
||||||
|
// User-defined local ignore patterns (settings.ignorePatterns), applied on top of
|
||||||
|
// remote/local .gitignore rules. Matched against the same vault/rootPath-relative
|
||||||
|
// path passed into isIgnored().
|
||||||
|
private readonly localIgnore: Ignore | null;
|
||||||
|
|
||||||
// Maps directory path (empty string for root) to Ignore instance
|
// Maps directory path (empty string for root) to Ignore instance
|
||||||
private readonly ignoreMap: Map<string, Ignore> = new Map();
|
private readonly ignoreMap: Map<string, Ignore> = new Map();
|
||||||
|
|
||||||
constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string, vaultFolder: string = '') {
|
constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string, vaultFolder: string = '', ignorePatterns: string = '') {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.gitService = gitService;
|
this.gitService = gitService;
|
||||||
this.branch = branch;
|
this.branch = branch;
|
||||||
this.rootPath = rootPath.replace(/^\/|\/$/g, '');
|
this.rootPath = rootPath.replace(/^\/|\/$/g, '');
|
||||||
this.vaultFolder = vaultFolder.replace(/^\/|\/$/g, '');
|
this.vaultFolder = vaultFolder.replace(/^\/|\/$/g, '');
|
||||||
|
this.localIgnore = ignorePatterns.trim() ? ignore().add(ignorePatterns) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNormalizedPath(path: string): string {
|
private getNormalizedPath(path: string): string {
|
||||||
|
|
@ -153,6 +158,8 @@ export class GitignoreManager {
|
||||||
* Checks if a given file path should be ignored based on loaded .gitignore rules.
|
* Checks if a given file path should be ignored based on loaded .gitignore rules.
|
||||||
*/
|
*/
|
||||||
isIgnored(filePath: string): boolean {
|
isIgnored(filePath: string): boolean {
|
||||||
|
if (this.localIgnore?.ignores(filePath)) return true;
|
||||||
|
|
||||||
const fullPath = this.rootPath ? `${this.rootPath}/${filePath}` : filePath;
|
const fullPath = this.rootPath ? `${this.rootPath}/${filePath}` : filePath;
|
||||||
|
|
||||||
for (const [dirPath, ig] of this.ignoreMap.entries()) {
|
for (const [dirPath, ig] of this.ignoreMap.entries()) {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export default class GitLabFilesPush extends Plugin {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.initializeGitService();
|
this.initializeGitService();
|
||||||
this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder);
|
this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder, this.settings.ignorePatterns);
|
||||||
this.sync = new SyncManager(this.app, this.gitService, this.settings, this.saveSettings.bind(this));
|
this.sync = new SyncManager(this.app, this.gitService, this.settings, this.saveSettings.bind(this));
|
||||||
|
|
||||||
this.pushRibbonEl = this.addRibbonIcon('upload-cloud', this.pushRibbonLabel(), async () => {
|
this.pushRibbonEl = this.addRibbonIcon('upload-cloud', this.pushRibbonLabel(), async () => {
|
||||||
|
|
|
||||||
107
src/settings.ts
107
src/settings.ts
|
|
@ -1,5 +1,6 @@
|
||||||
import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian';
|
import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian';
|
||||||
import GitLabFilesPush from "./main";
|
import GitLabFilesPush from "./main";
|
||||||
|
import { ConnectionTestResult } from "./services/git-service-base";
|
||||||
|
|
||||||
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
|
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
|
||||||
// the plugin still type-checks against older Obsidian typings (minAppVersion
|
// the plugin still type-checks against older Obsidian typings (minAppVersion
|
||||||
|
|
@ -44,6 +45,8 @@ export interface GitLabFilesPushSettings {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
vaultFolder: string;
|
vaultFolder: string;
|
||||||
symlinkHandling: SymlinkHandling;
|
symlinkHandling: SymlinkHandling;
|
||||||
|
/** Multi-line, .gitignore-style patterns applied locally, in addition to the remote repo's .gitignore rules. */
|
||||||
|
ignorePatterns: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServiceName(settings: GitLabFilesPushSettings): string {
|
export function getServiceName(settings: GitLabFilesPushSettings): string {
|
||||||
|
|
@ -81,11 +84,19 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
|
||||||
branch: 'main',
|
branch: 'main',
|
||||||
syncMetadata: {},
|
syncMetadata: {},
|
||||||
vaultFolder: '',
|
vaultFolder: '',
|
||||||
symlinkHandling: 'real'
|
symlinkHandling: 'real',
|
||||||
|
ignorePatterns: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConnectionStatusState = 'checking' | 'connected' | 'disconnected';
|
||||||
|
|
||||||
|
const CONNECTION_TEST_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
export class GitLabSyncSettingTab extends PluginSettingTab {
|
export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
plugin: GitLabFilesPush;
|
plugin: GitLabFilesPush;
|
||||||
|
private statusBadgeEl: HTMLElement | null = null;
|
||||||
|
private connectionTestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private connectionTestSeq = 0;
|
||||||
|
|
||||||
constructor(app: App, plugin: GitLabFilesPush) {
|
constructor(app: App, plugin: GitLabFilesPush) {
|
||||||
super(app, plugin);
|
super(app, plugin);
|
||||||
|
|
@ -120,9 +131,75 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuilding the whole settings tab (renderSettings) to refresh the badge
|
||||||
|
// would empty and recreate every field, stealing focus mid-typing. The
|
||||||
|
// badge element is instead created once per renderSettings pass and
|
||||||
|
// updated in place by setStatusBadge().
|
||||||
|
private renderConnectionStatus(containerEl: HTMLElement): void {
|
||||||
|
this.statusBadgeEl = containerEl.createDiv({ cls: 'gfs-connection-status' });
|
||||||
|
this.setStatusBadge('checking');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatusBadge(state: ConnectionStatusState, detail?: string): void {
|
||||||
|
const badge = this.statusBadgeEl;
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
badge.removeClass('is-checking');
|
||||||
|
badge.removeClass('is-connected');
|
||||||
|
badge.removeClass('is-disconnected');
|
||||||
|
badge.addClass(`is-${state}`);
|
||||||
|
|
||||||
|
const labels: Record<ConnectionStatusState, string> = {
|
||||||
|
checking: 'Checking…',
|
||||||
|
connected: 'Connected',
|
||||||
|
disconnected: 'Not connected'
|
||||||
|
};
|
||||||
|
const label = labels[state];
|
||||||
|
badge.setText(detail ? `${label} — ${detail}` : label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced so token/branch fields (which call this on every keystroke)
|
||||||
|
// don't hit the remote API on every character typed.
|
||||||
|
private scheduleConnectionTest(): void {
|
||||||
|
if (this.connectionTestTimer) {
|
||||||
|
clearTimeout(this.connectionTestTimer);
|
||||||
|
}
|
||||||
|
this.connectionTestTimer = setTimeout(() => {
|
||||||
|
this.connectionTestTimer = null;
|
||||||
|
void this.testConnectionSilently();
|
||||||
|
}, CONNECTION_TEST_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testConnectionSilently(): Promise<ConnectionTestResult> {
|
||||||
|
const seq = ++this.connectionTestSeq;
|
||||||
|
this.setStatusBadge('checking');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.plugin.gitService.testConnection(this.plugin.settings.branch);
|
||||||
|
if (seq !== this.connectionTestSeq) return result;
|
||||||
|
|
||||||
|
if (!result.repoOk) {
|
||||||
|
this.setStatusBadge('disconnected', result.error ?? 'could not reach the repository');
|
||||||
|
} else if (!result.branchOk) {
|
||||||
|
this.setStatusBadge('disconnected', `branch "${this.plugin.settings.branch}" not found`);
|
||||||
|
} else {
|
||||||
|
this.setStatusBadge('connected');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (seq === this.connectionTestSeq) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
this.setStatusBadge('disconnected', message);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private renderSettings(containerEl: HTMLElement): void {
|
private renderSettings(containerEl: HTMLElement): void {
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
|
|
||||||
|
this.renderConnectionStatus(containerEl);
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName('Git service')
|
.setName('Git service')
|
||||||
.setDesc('Choose your Git hosting service')
|
.setDesc('Choose your Git hosting service')
|
||||||
|
|
@ -157,6 +234,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
.onChange((value) => {
|
.onChange((value) => {
|
||||||
this.plugin.settings.branch = value || 'main';
|
this.plugin.settings.branch = value || 'main';
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
|
|
@ -182,6 +260,19 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Ignore patterns')
|
||||||
|
.setDesc('Optional: .gitignore-style patterns (one per line) to exclude local files from sync, in addition to the repository\'s own .gitignore.')
|
||||||
|
.addTextArea(text => {
|
||||||
|
text.setPlaceholder(`${this.app.vault.configDir}/\n*.tmp`)
|
||||||
|
.setValue(this.plugin.settings.ignorePatterns)
|
||||||
|
.onChange((value) => {
|
||||||
|
this.plugin.settings.ignorePatterns = value;
|
||||||
|
void this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
text.inputEl.rows = 4;
|
||||||
|
});
|
||||||
|
|
||||||
// "Real symlink" needs the Git Data API, which only GitHub offers. For
|
// "Real symlink" needs the Git Data API, which only GitHub offers. For
|
||||||
// other providers, offer follow/skip only so the option can't mislead.
|
// other providers, offer follow/skip only so the option can't mislead.
|
||||||
const supportsRealSymlink = this.plugin.settings.serviceType === 'github';
|
const supportsRealSymlink = this.plugin.settings.serviceType === 'github';
|
||||||
|
|
@ -209,7 +300,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
.setButtonText('Test connection')
|
.setButtonText('Test connection')
|
||||||
.onClick(async () => {
|
.onClick(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await this.plugin.gitService.testConnection(this.plugin.settings.branch);
|
const result = await this.testConnectionSilently();
|
||||||
if (!result.repoOk) {
|
if (!result.repoOk) {
|
||||||
new Notice(`Connection failed: ${result.error ?? 'could not reach the repository'}`);
|
new Notice(`Connection failed: ${result.error ?? 'could not reach the repository'}`);
|
||||||
} else if (!result.branchOk) {
|
} else if (!result.branchOk) {
|
||||||
|
|
@ -226,6 +317,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
new Notice(`Connection failed: ${message}`);
|
new Notice(`Connection failed: ${message}`);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token fields are masked like a password input (with a toggle to reveal
|
// Token fields are masked like a password input (with a toggle to reveal
|
||||||
|
|
@ -265,6 +358,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.gitlabToken = value;
|
this.plugin.settings.gitlabToken = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -278,6 +372,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.gitlabBaseUrl = value || 'https://gitlab.com';
|
this.plugin.settings.gitlabBaseUrl = value || 'https://gitlab.com';
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
|
|
@ -290,6 +385,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.projectId = value;
|
this.plugin.settings.projectId = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,6 +399,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.giteaToken = value;
|
this.plugin.settings.giteaToken = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -316,6 +413,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.giteaBaseUrl = value;
|
this.plugin.settings.giteaBaseUrl = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
|
|
@ -328,6 +426,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.giteaOwner = value;
|
this.plugin.settings.giteaOwner = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
|
|
@ -340,6 +439,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.giteaRepo = value;
|
this.plugin.settings.giteaRepo = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,6 +453,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.githubToken = value;
|
this.plugin.settings.githubToken = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -366,6 +467,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.githubOwner = value;
|
this.plugin.settings.githubOwner = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
|
|
@ -378,6 +480,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||||
this.plugin.settings.githubRepo = value;
|
this.plugin.settings.githubRepo = value;
|
||||||
void this.plugin.saveSettings();
|
void this.plugin.saveSettings();
|
||||||
this.plugin.initializeGitService();
|
this.plugin.initializeGitService();
|
||||||
|
this.scheduleConnectionTest();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { App, Modal, Setting } from 'obsidian';
|
import { App, Modal, Setting } from 'obsidian';
|
||||||
|
|
||||||
|
type ConflictPanelName = 'diff' | 'local' | 'remote';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the "destructive" button style, but only when the running Obsidian
|
* Apply the "destructive" button style, but only when the running Obsidian
|
||||||
* supports it. ButtonComponent.setDestructive() was added in Obsidian 1.13; on
|
* supports it. ButtonComponent.setDestructive() was added in Obsidian 1.13; on
|
||||||
|
|
@ -39,22 +41,47 @@ export class SyncConflictModal extends Modal {
|
||||||
cls: 'conflict-description'
|
cls: 'conflict-description'
|
||||||
});
|
});
|
||||||
|
|
||||||
const diffContainer = contentEl.createDiv({ cls: 'conflict-diff-container' });
|
const panels = {} as Record<ConflictPanelName, HTMLElement>;
|
||||||
|
const tabs = {} as Record<ConflictPanelName, HTMLElement>;
|
||||||
|
|
||||||
const localSection = diffContainer.createDiv({ cls: 'conflict-section' });
|
const setActivePanel = (name: ConflictPanelName) => {
|
||||||
|
(Object.keys(panels) as ConflictPanelName[]).forEach(key => {
|
||||||
|
panels[key].toggleClass('is-active', key === name);
|
||||||
|
tabs[key].toggleClass('is-active', key === name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabsContainer = contentEl.createDiv({ cls: 'conflict-tabs' });
|
||||||
|
const tabLabels: Record<ConflictPanelName, string> = { diff: 'Diff', local: 'Local', remote: 'Remote' };
|
||||||
|
(['diff', 'local', 'remote'] as const).forEach(name => {
|
||||||
|
const tab = tabsContainer.createEl('button', { text: tabLabels[name], cls: 'conflict-tab' });
|
||||||
|
tab.addEventListener('click', () => setActivePanel(name));
|
||||||
|
tabs[name] = tab;
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentArea = contentEl.createDiv({ cls: 'conflict-content-area' });
|
||||||
|
|
||||||
|
const diffContainer = contentArea.createDiv({ cls: 'conflict-diff-container' });
|
||||||
|
|
||||||
|
const localSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' });
|
||||||
localSection.createEl('h3', { text: 'Local version' });
|
localSection.createEl('h3', { text: 'Local version' });
|
||||||
const localPre = localSection.createEl('pre', { cls: 'conflict-content' });
|
const localPre = localSection.createEl('pre', { cls: 'conflict-content' });
|
||||||
localPre.createEl('code', { text: this.localContent });
|
localPre.createEl('code', { text: this.localContent });
|
||||||
|
panels.local = localSection;
|
||||||
|
|
||||||
const remoteSection = diffContainer.createDiv({ cls: 'conflict-section' });
|
const remoteSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' });
|
||||||
remoteSection.createEl('h3', { text: 'Remote version' });
|
remoteSection.createEl('h3', { text: 'Remote version' });
|
||||||
const remotePre = remoteSection.createEl('pre', { cls: 'conflict-content' });
|
const remotePre = remoteSection.createEl('pre', { cls: 'conflict-content' });
|
||||||
remotePre.createEl('code', { text: this.remoteContent });
|
remotePre.createEl('code', { text: this.remoteContent });
|
||||||
|
panels.remote = remoteSection;
|
||||||
|
|
||||||
const diffSection = contentEl.createDiv({ cls: 'conflict-diff-section' });
|
const diffSection = contentArea.createDiv({ cls: 'conflict-diff-section conflict-panel' });
|
||||||
diffSection.createEl('h3', { text: 'Differences' });
|
diffSection.createEl('h3', { text: 'Differences' });
|
||||||
const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' });
|
const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' });
|
||||||
this.renderDiff(diffPre);
|
this.renderDiff(diffPre);
|
||||||
|
panels.diff = diffSection;
|
||||||
|
|
||||||
|
setActivePanel('diff');
|
||||||
|
|
||||||
const buttonContainer = contentEl.createDiv({ cls: 'conflict-buttons' });
|
const buttonContainer = contentEl.createDiv({ cls: 'conflict-buttons' });
|
||||||
|
|
||||||
|
|
|
||||||
88
styles.css
88
styles.css
|
|
@ -570,13 +570,90 @@
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Settings connection status badge ──────────────────────────── */
|
||||||
|
.gfs-connection-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gfs-connection-status::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gfs-connection-status.is-checking {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gfs-connection-status.is-connected {
|
||||||
|
color: var(--text-success);
|
||||||
|
background: var(--background-modifier-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gfs-connection-status.is-disconnected {
|
||||||
|
color: var(--text-error);
|
||||||
|
background: var(--background-modifier-error);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Conflict Modal ─────────────────────────────────────────────── */
|
/* ── Conflict Modal ─────────────────────────────────────────────── */
|
||||||
.sync-conflict-modal { max-width: 900px; }
|
.sync-conflict-modal.modal {
|
||||||
|
width: min(1100px, 92vw);
|
||||||
|
height: min(85vh, 800px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-conflict-modal .modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.conflict-description {
|
.conflict-description {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 12px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-tabs {
|
||||||
|
display: none;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-tab {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
background: var(--background-secondary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-tab.is-active {
|
||||||
|
background: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-content-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conflict-diff-container {
|
.conflict-diff-container {
|
||||||
|
|
@ -588,6 +665,11 @@
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.conflict-diff-container { grid-template-columns: 1fr; }
|
.conflict-diff-container { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.conflict-tabs { display: flex; }
|
||||||
|
|
||||||
|
.conflict-panel { display: none; }
|
||||||
|
.conflict-panel.is-active { display: block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.conflict-section h3,
|
.conflict-section h3,
|
||||||
|
|
@ -599,7 +681,6 @@
|
||||||
|
|
||||||
.conflict-content,
|
.conflict-content,
|
||||||
.conflict-diff {
|
.conflict-diff {
|
||||||
max-height: 280px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: var(--background-secondary);
|
background: var(--background-secondary);
|
||||||
|
|
@ -620,6 +701,7 @@
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--background-modifier-border);
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,58 @@ describe('GitignoreManager', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('local ignorePatterns setting', () => {
|
||||||
|
it('ignores files matching a local pattern even with no remote/local .gitignore', async () => {
|
||||||
|
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
|
||||||
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||||
|
vi.mocked(adapter.exists).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', 'secrets/\n*.private');
|
||||||
|
await localManager.loadGitignores();
|
||||||
|
|
||||||
|
expect(localManager.isIgnored('secrets/key.txt')).toBe(true);
|
||||||
|
expect(localManager.isIgnored('note.private')).toBe(true);
|
||||||
|
expect(localManager.isIgnored('note.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies local patterns in addition to remote .gitignore, not instead of it', async () => {
|
||||||
|
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
|
||||||
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||||
|
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||||
|
vi.mocked(adapter.read).mockResolvedValue('*.log');
|
||||||
|
|
||||||
|
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', '*.tmp');
|
||||||
|
await localManager.loadGitignores();
|
||||||
|
|
||||||
|
expect(localManager.isIgnored('test.log')).toBe(true);
|
||||||
|
expect(localManager.isIgnored('test.tmp')).toBe(true);
|
||||||
|
expect(localManager.isIgnored('test.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores blank ignorePatterns without throwing', async () => {
|
||||||
|
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
|
||||||
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||||
|
vi.mocked(adapter.exists).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', ' \n ');
|
||||||
|
await localManager.loadGitignores();
|
||||||
|
|
||||||
|
expect(localManager.isIgnored('note.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects comments and negation in local patterns', async () => {
|
||||||
|
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
|
||||||
|
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||||
|
vi.mocked(adapter.exists).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', '# comment\ndraft/*\n!draft/keep.md');
|
||||||
|
await localManager.loadGitignores();
|
||||||
|
|
||||||
|
expect(localManager.isIgnored('draft/scratch.md')).toBe(true);
|
||||||
|
expect(localManager.isIgnored('draft/keep.md')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('complex patterns', () => {
|
describe('complex patterns', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
|
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ const mockSettings: GitLabFilesPushSettings = {
|
||||||
vaultFolder: 'Work',
|
vaultFolder: 'Work',
|
||||||
syncMetadata: {},
|
syncMetadata: {},
|
||||||
symlinkHandling: 'real',
|
symlinkHandling: 'real',
|
||||||
|
ignorePatterns: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('SyncManager Mapping', () => {
|
describe('SyncManager Mapping', () => {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ const mockSettings: GitLabFilesPushSettings = {
|
||||||
rootPath: '',
|
rootPath: '',
|
||||||
syncMetadata: {},
|
syncMetadata: {},
|
||||||
vaultFolder: '',
|
vaultFolder: '',
|
||||||
symlinkHandling: 'real'
|
symlinkHandling: 'real',
|
||||||
|
ignorePatterns: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('SyncManager', () => {
|
describe('SyncManager', () => {
|
||||||
|
|
|
||||||
211
tests/setup.ts
211
tests/setup.ts
|
|
@ -5,6 +5,16 @@ if (typeof document === 'undefined') {
|
||||||
(globalThis as unknown as { document: unknown }).document = {
|
(globalThis as unknown as { document: unknown }).document = {
|
||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
|
createElement: vi.fn(() => ({
|
||||||
|
classList: { add: vi.fn(), contains: vi.fn(), toggle: vi.fn() },
|
||||||
|
appendChild: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
textContent: '',
|
||||||
|
type: 'text',
|
||||||
|
empty() {},
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|
@ -14,27 +24,206 @@ if (typeof window === 'undefined') {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock Obsidian API components
|
function createButtonElement(): HTMLButtonElement {
|
||||||
|
return document.createElement('button');
|
||||||
|
}
|
||||||
|
|
||||||
export const Plugin = class {};
|
export const Plugin = class {};
|
||||||
export const PluginSettingTab = class {
|
export const PluginSettingTab = class {
|
||||||
constructor() {}
|
app: unknown;
|
||||||
|
plugin: unknown;
|
||||||
|
containerEl: HTMLElement;
|
||||||
|
|
||||||
|
constructor(app?: unknown, plugin?: unknown) {
|
||||||
|
this.app = app;
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.containerEl = document.createElement('div') as HTMLElement;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TextComponent = class {
|
||||||
|
inputEl: HTMLInputElement;
|
||||||
|
private changeHandler?: (value: string) => void;
|
||||||
|
|
||||||
|
constructor(containerEl?: HTMLElement) {
|
||||||
|
this.inputEl = document.createElement('input');
|
||||||
|
containerEl?.appendChild(this.inputEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlaceholder(value: string) {
|
||||||
|
this.inputEl.placeholder = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(value: string) {
|
||||||
|
this.inputEl.value = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(handler: (value: string) => void) {
|
||||||
|
this.changeHandler = handler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerChange(value: string) {
|
||||||
|
this.inputEl.value = value;
|
||||||
|
this.changeHandler?.(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TextAreaComponent = class {
|
||||||
|
inputEl: HTMLTextAreaElement;
|
||||||
|
private changeHandler?: (value: string) => void;
|
||||||
|
|
||||||
|
constructor(containerEl?: HTMLElement) {
|
||||||
|
this.inputEl = document.createElement('textarea');
|
||||||
|
containerEl?.appendChild(this.inputEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlaceholder(value: string) {
|
||||||
|
this.inputEl.placeholder = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(value: string) {
|
||||||
|
this.inputEl.value = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(handler: (value: string) => void) {
|
||||||
|
this.changeHandler = handler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerChange(value: string) {
|
||||||
|
this.inputEl.value = value;
|
||||||
|
this.changeHandler?.(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DropdownComponent = class {
|
||||||
|
private changeHandler?: (value: string) => void;
|
||||||
|
|
||||||
|
addOption() { return this; }
|
||||||
|
setValue() { return this; }
|
||||||
|
onChange(handler: (value: string) => void) {
|
||||||
|
this.changeHandler = handler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
triggerChange(value: string) {
|
||||||
|
this.changeHandler?.(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ButtonComponent = class {
|
||||||
|
buttonEl: HTMLButtonElement;
|
||||||
|
|
||||||
|
constructor(containerEl?: HTMLElement) {
|
||||||
|
this.buttonEl = createButtonElement();
|
||||||
|
containerEl?.appendChild(this.buttonEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setButtonText(text: string) {
|
||||||
|
this.buttonEl.textContent = text;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTooltip(text: string) {
|
||||||
|
this.buttonEl.title = text;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCta() {
|
||||||
|
this.buttonEl.classList.add('mod-cta');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWarning() {
|
||||||
|
this.buttonEl.classList.add('mod-warning');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDestructive() {
|
||||||
|
this.buttonEl.classList.add('mod-destructive');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIcon(icon: string) {
|
||||||
|
this.buttonEl.dataset.icon = icon;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick(handler: () => void) {
|
||||||
|
this.buttonEl.addEventListener('click', handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExtraButtonComponent = class extends ButtonComponent {};
|
||||||
|
|
||||||
export const Setting = class {
|
export const Setting = class {
|
||||||
constructor() {}
|
containerEl?: HTMLElement;
|
||||||
|
|
||||||
|
constructor(containerEl?: HTMLElement) {
|
||||||
|
this.containerEl = containerEl;
|
||||||
|
}
|
||||||
|
|
||||||
setName() { return this; }
|
setName() { return this; }
|
||||||
setDesc() { return this; }
|
setDesc() { return this; }
|
||||||
addText() { return this; }
|
setHeading() { return this; }
|
||||||
addToggle() { return this; }
|
addToggle() { return this; }
|
||||||
addButton() { return this; }
|
addText(callback?: (component: InstanceType<typeof TextComponent>) => void) {
|
||||||
|
if (callback) callback(new TextComponent(this.containerEl));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addTextArea(callback?: (component: InstanceType<typeof TextAreaComponent>) => void) {
|
||||||
|
if (callback) callback(new TextAreaComponent(this.containerEl));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addButton(callback?: (component: InstanceType<typeof ButtonComponent>) => void) {
|
||||||
|
if (callback) callback(new ButtonComponent(this.containerEl));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addExtraButton(callback?: (component: InstanceType<typeof ExtraButtonComponent>) => void) {
|
||||||
|
if (callback) callback(new ExtraButtonComponent(this.containerEl));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
addDropdown(callback?: (component: InstanceType<typeof DropdownComponent>) => void) {
|
||||||
|
if (callback) callback(new DropdownComponent());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Notice = class {
|
export const Notice = class {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
setMessage() {}
|
||||||
|
hide() {}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Modal = class {
|
export const Modal = class {
|
||||||
constructor() {}
|
app: unknown;
|
||||||
open() {}
|
contentEl: HTMLElement;
|
||||||
close() {}
|
|
||||||
|
constructor(app?: unknown) {
|
||||||
|
this.app = app;
|
||||||
|
this.contentEl = document.createElement('div') as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
const withOnOpen = this as unknown as { onOpen?: () => void };
|
||||||
|
if (typeof withOnOpen.onOpen === 'function') {
|
||||||
|
withOnOpen.onOpen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
const withOnClose = this as unknown as { onClose?: () => void };
|
||||||
|
if (typeof withOnClose.onClose === 'function') {
|
||||||
|
withOnClose.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MarkdownView = class {};
|
export const MarkdownView = class {};
|
||||||
export const Editor = class {};
|
export const Editor = class {};
|
||||||
export const App = class {
|
export const App = class {
|
||||||
|
|
@ -47,6 +236,7 @@ export const App = class {
|
||||||
modify: vi.fn(),
|
modify: vi.fn(),
|
||||||
getFileByPath: vi.fn(),
|
getFileByPath: vi.fn(),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
|
configDir: 'mock-config-dir',
|
||||||
adapter: {
|
adapter: {
|
||||||
getBasePath: vi.fn().mockReturnValue('/mock/path'),
|
getBasePath: vi.fn().mockReturnValue('/mock/path'),
|
||||||
},
|
},
|
||||||
|
|
@ -68,6 +258,11 @@ vi.mock('obsidian', () => ({
|
||||||
Editor,
|
Editor,
|
||||||
App,
|
App,
|
||||||
TFile,
|
TFile,
|
||||||
|
TextComponent,
|
||||||
|
TextAreaComponent,
|
||||||
|
DropdownComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
ExtraButtonComponent,
|
||||||
requestUrl,
|
requestUrl,
|
||||||
setTooltip,
|
setTooltip,
|
||||||
setIcon,
|
setIcon,
|
||||||
|
|
|
||||||
95
tests/ui/SettingsConnectionStatus.test.ts
Normal file
95
tests/ui/SettingsConnectionStatus.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-deprecated -- exercising the Obsidian < 1.13 display() compat fallback intentionally */
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { App } from 'obsidian';
|
||||||
|
import { DEFAULT_SETTINGS, GitLabSyncSettingTab } from '../../src/settings';
|
||||||
|
import GitLabFilesPush from '../../src/main';
|
||||||
|
import { createContainer, setupObsidianDOM } from './setup-dom';
|
||||||
|
|
||||||
|
vi.mock('../../src/main', () => ({
|
||||||
|
default: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeAll(() => { setupObsidianDOM(); });
|
||||||
|
|
||||||
|
function createPluginStub(testConnection: ReturnType<typeof vi.fn>): GitLabFilesPush {
|
||||||
|
return {
|
||||||
|
settings: { ...DEFAULT_SETTINGS },
|
||||||
|
saveSettings: vi.fn().mockResolvedValue(undefined),
|
||||||
|
initializeGitService: vi.fn(),
|
||||||
|
gitService: { testConnection },
|
||||||
|
} as unknown as GitLabFilesPush;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleConnectionTest(tab: GitLabSyncSettingTab): void {
|
||||||
|
(tab as unknown as { scheduleConnectionTest: () => void }).scheduleConnectionTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GitLabSyncSettingTab connection status badge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows checking then connected after opening the tab', async () => {
|
||||||
|
const testConnection = vi.fn().mockResolvedValue({ repoOk: true, branchOk: true });
|
||||||
|
const tab = new GitLabSyncSettingTab(new App(), createPluginStub(testConnection));
|
||||||
|
tab.containerEl = createContainer();
|
||||||
|
|
||||||
|
tab.display();
|
||||||
|
|
||||||
|
const badge = tab.containerEl.querySelector('.gfs-connection-status') as HTMLElement;
|
||||||
|
expect(badge.classList.contains('is-checking')).toBe(true);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
|
||||||
|
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(badge.classList.contains('is-connected')).toBe(true);
|
||||||
|
expect(badge.textContent).toBe('Connected');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces repeated field edits into a single connection test', async () => {
|
||||||
|
const testConnection = vi.fn().mockResolvedValue({ repoOk: false, branchOk: false, error: 'bad token' });
|
||||||
|
const plugin = createPluginStub(testConnection);
|
||||||
|
const tab = new GitLabSyncSettingTab(new App(), plugin);
|
||||||
|
tab.containerEl = createContainer();
|
||||||
|
|
||||||
|
tab.display();
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
testConnection.mockClear();
|
||||||
|
|
||||||
|
// Simulate rapid keystrokes in the token field via the debounced hook directly.
|
||||||
|
scheduleConnectionTest(tab);
|
||||||
|
scheduleConnectionTest(tab);
|
||||||
|
scheduleConnectionTest(tab);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(799);
|
||||||
|
expect(testConnection).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const badge = tab.containerEl.querySelector('.gfs-connection-status') as HTMLElement;
|
||||||
|
expect(badge.classList.contains('is-disconnected')).toBe(true);
|
||||||
|
expect(badge.textContent).toContain('bad token');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitLabSyncSettingTab ignore patterns setting', () => {
|
||||||
|
it('renders a textarea seeded with the saved ignorePatterns value', async () => {
|
||||||
|
const plugin = createPluginStub(vi.fn().mockResolvedValue({ repoOk: true, branchOk: true }));
|
||||||
|
plugin.settings.ignorePatterns = 'draft/\n*.tmp';
|
||||||
|
const tab = new GitLabSyncSettingTab(new App(), plugin);
|
||||||
|
tab.containerEl = createContainer();
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
tab.display();
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const textarea = tab.containerEl.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
expect(textarea.value).toBe('draft/\n*.tmp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||||
import { applyDestructiveStyle } from '../../src/ui/SyncConflictModal';
|
import { App } from 'obsidian';
|
||||||
|
import { applyDestructiveStyle, SyncConflictModal } from '../../src/ui/SyncConflictModal';
|
||||||
|
import { createContainer, setupObsidianDOM } from './setup-dom';
|
||||||
|
|
||||||
// Guards the backward-compatibility fix that lets the plugin run on Obsidian
|
// Guards the backward-compatibility fix that lets the plugin run on Obsidian
|
||||||
// down to minAppVersion 1.11.0. ButtonComponent.setDestructive() only exists on
|
// down to minAppVersion 1.11.0. ButtonComponent.setDestructive() only exists on
|
||||||
|
|
@ -26,3 +28,30 @@ describe('applyDestructiveStyle (Obsidian version compatibility)', () => {
|
||||||
expect(() => applyDestructiveStyle(btn)).not.toThrow();
|
expect(() => applyDestructiveStyle(btn)).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SyncConflictModal', () => {
|
||||||
|
beforeAll(() => { setupObsidianDOM(); });
|
||||||
|
|
||||||
|
it('defaults to the diff panel and switches panels via tabs', () => {
|
||||||
|
const modal = new SyncConflictModal(new App(), 'note.md', 'local', 'remote', vi.fn());
|
||||||
|
modal.contentEl = createContainer();
|
||||||
|
|
||||||
|
modal.onOpen();
|
||||||
|
|
||||||
|
const contentEl = modal.contentEl;
|
||||||
|
const tabs = Array.from(contentEl.querySelectorAll<HTMLElement>('.conflict-tab'));
|
||||||
|
const panels = Array.from(contentEl.querySelectorAll<HTMLElement>('.conflict-panel'));
|
||||||
|
|
||||||
|
const activePanel = () => panels.find(panel => panel.classList.contains('is-active'));
|
||||||
|
const activeTab = () => tabs.find(tab => tab.classList.contains('is-active'));
|
||||||
|
|
||||||
|
expect(activeTab()?.textContent).toBe('Diff');
|
||||||
|
expect(activePanel()?.classList.contains('conflict-diff-section')).toBe(true);
|
||||||
|
|
||||||
|
const localTab = tabs.find(tab => tab.textContent === 'Local');
|
||||||
|
localTab?.dispatchEvent(new Event('click'));
|
||||||
|
|
||||||
|
expect(activeTab()?.textContent).toBe('Local');
|
||||||
|
expect(activePanel()?.classList.contains('conflict-section')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,12 @@ export function setupObsidianDOM(): void {
|
||||||
(this as HTMLElement).appendChild(el);
|
(this as HTMLElement).appendChild(el);
|
||||||
return el as unknown as HTMLSpanElement;
|
return el as unknown as HTMLSpanElement;
|
||||||
},
|
},
|
||||||
|
addClass(cls: string): void {
|
||||||
|
(this as HTMLElement).classList.add(cls);
|
||||||
|
},
|
||||||
|
removeClass(cls: string): void {
|
||||||
|
(this as HTMLElement).classList.remove(cls);
|
||||||
|
},
|
||||||
hasClass(cls: string): boolean {
|
hasClass(cls: string): boolean {
|
||||||
return (this as HTMLElement).classList.contains(cls);
|
return (this as HTMLElement).classList.contains(cls);
|
||||||
},
|
},
|
||||||
|
|
@ -59,6 +65,9 @@ export function setupObsidianDOM(): void {
|
||||||
setText(text: string): void {
|
setText(text: string): void {
|
||||||
(this as HTMLElement).textContent = text;
|
(this as HTMLElement).textContent = text;
|
||||||
},
|
},
|
||||||
|
empty(): void {
|
||||||
|
(this as HTMLElement).replaceChildren();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue