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:
ClaudiaFang 2026-07-13 12:19:17 +00:00
parent ef238cea59
commit 28f4f8efd0
12 changed files with 624 additions and 23 deletions

View file

@ -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()) {

View file

@ -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 () => {

View file

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

View file

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

View file

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

View file

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

View file

@ -53,6 +53,7 @@ const mockSettings: GitLabFilesPushSettings = {
vaultFolder: 'Work', vaultFolder: 'Work',
syncMetadata: {}, syncMetadata: {},
symlinkHandling: 'real', symlinkHandling: 'real',
ignorePatterns: '',
}; };
describe('SyncManager Mapping', () => { describe('SyncManager Mapping', () => {

View file

@ -59,7 +59,8 @@ const mockSettings: GitLabFilesPushSettings = {
rootPath: '', rootPath: '',
syncMetadata: {}, syncMetadata: {},
vaultFolder: '', vaultFolder: '',
symlinkHandling: 'real' symlinkHandling: 'real',
ignorePatterns: ''
}; };
describe('SyncManager', () => { describe('SyncManager', () => {

View file

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

View 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');
});
});

View file

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

View file

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