From 8c31ade0d7e55260f644c1c62f7ae1c04dd685ef Mon Sep 17 00:00:00 2001 From: Mark Rhoades-Brown Date: Fri, 29 May 2026 23:26:06 +0100 Subject: [PATCH 1/2] feat: migrate settings UI to declarative API (Obsidian 1.13.0) --- manifest.json | 2 +- src/ui/settingsTab.ts | 1148 ++++++++++++++++++++++----------------- tests/mocks/obsidian.ts | 6 + versions.json | 2 +- 4 files changed, 644 insertions(+), 514 deletions(-) diff --git a/manifest.json b/manifest.json index def4177..71b2c63 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "id": "github-octokit", "name": "GitHub Octokit Sync", "version": "0.6.0", - "minAppVersion": "1.12.7", + "minAppVersion": "1.13.0", "description": "Sync your vault with GitHub using the Octokit API.", "author": "M Rhoades-Brown", "authorUrl": "https://github.com/rhoades-brown", diff --git a/src/ui/settingsTab.ts b/src/ui/settingsTab.ts index 6871f49..b9425f5 100644 --- a/src/ui/settingsTab.ts +++ b/src/ui/settingsTab.ts @@ -1,12 +1,45 @@ import { App, Notice, PluginSettingTab, Setting } from 'obsidian'; +import type { SettingDefinitionItem } from 'obsidian'; import type { GitHubRepo } from '../services/githubService'; import { LogLevel } from '../services/loggerService'; -import { AdditionalRepoConfig, ConflictResolution } from '../types/settings'; +import { AdditionalRepoConfig } from '../types/settings'; import { LogViewerModal } from './modals/LogViewerModal'; import type GitHubOctokitPlugin from '../../main'; +// ============================================================================ +// Dot-notation helpers for nested settings +// ============================================================================ + +function getPath(obj: Record, path: string): unknown { + let cursor: unknown = obj; + for (const part of path.split('.')) { + if (cursor === null || typeof cursor !== 'object') return undefined; + cursor = (cursor as Record)[part]; + } + return cursor; +} + +function setPath(obj: Record, path: string, value: unknown): void { + const parts = path.split('.'); + const last = parts.pop()!; + let cursor: Record = obj; + for (const part of parts) { + let next = cursor[part]; + if (next === null || typeof next !== 'object') { + next = {}; + cursor[part] = next; + } + cursor = next as Record; + } + cursor[last] = value; +} + /** - * Settings tab for the GitHub Octokit plugin + * Settings tab for the GitHub Octokit plugin. + * + * Uses the declarative settings API introduced in Obsidian 1.13.0. + * Settings are defined via getSettingDefinitions() and the framework + * handles rendering, search indexing, and auto-save. */ export class GitHubOctokitSettingTab extends PluginSettingTab { plugin: GitHubOctokitPlugin; @@ -17,161 +50,207 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { this.plugin = plugin; } - display(): void { - const { containerEl } = this; - containerEl.empty(); + // ======================================================================== + // Dot-notation read/write for nested settings (auth.token, etc.) + // ======================================================================== - this.renderAuthSection(containerEl); - this.renderRepoSection(containerEl); - this.renderAdditionalReposSection(containerEl); - this.renderIgnorePatternsSection(containerEl); - this.renderSyncTriggersSection(containerEl); - this.renderCommitSection(containerEl); - this.renderConflictSection(containerEl); - this.renderUISection(containerEl); - this.renderLoggingSection(containerEl); + getControlValue(key: string): unknown { + return getPath( + this.plugin.settings as unknown as Record, + key, + ); } - private renderAuthSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('GitHub authentication').setHeading(); - - // Connection status - const statusEl = containerEl.createDiv({ cls: 'github-octokit-status' }); - this.updateConnectionStatus(statusEl); - - new Setting(containerEl) - .setName('Personal access token') - .setDesc('Personal access token with repo access. Create one in your account developer settings.') - .addText(text => { - text - .setPlaceholder('Paste token here') - .setValue(this.plugin.settings.auth.token) - .onChange(async (value) => { - this.plugin.settings.auth.token = value; - this.plugin.settings.auth.tokenValidated = false; - await this.plugin.saveSettings(); - }); - text.inputEl.type = 'password'; - }) - .addButton(button => button - .setButtonText('Connect') - .setCta() - .onClick(async () => { - button.setButtonText('Connecting...'); - button.setDisabled(true); - - const success = await this.plugin.validateAndConnect(); - - if (success) { - new Notice(`Connected to GitHub as ${this.plugin.githubService.user?.login}`); - await this.loadRepositories(); - } else { - new Notice('Failed to connect to GitHub. Check your token.'); - } - - // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions - this.display(); - })); + async setControlValue(key: string, value: unknown): Promise { + setPath( + this.plugin.settings as unknown as Record, + key, + value, + ); + await this.plugin.saveData(this.plugin.settings); } - private renderRepoSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Repository').setHeading(); - - const repoSetting = new Setting(containerEl) - .setName('Repository') - .setDesc('Select a repository to sync with'); - - if (this.plugin.settings.auth.tokenValidated) { - repoSetting.addDropdown(async dropdown => { - dropdown.addOption('', 'Select a repository...'); - - if (this.repositories.length === 0) { - await this.loadRepositories(); - } - - for (const repo of this.repositories) { - dropdown.addOption(repo.fullName, repo.fullName); - } - - dropdown.setValue(this.plugin.settings.repo - ? `${this.plugin.settings.repo.owner}/${this.plugin.settings.repo.name}` - : ''); - - dropdown.onChange(async (value) => { - if (value) { - const repo = this.repositories.find(r => r.fullName === value); - if (repo) { - this.plugin.settings.repo = { - owner: repo.owner, - name: repo.name, - branch: repo.defaultBranch, - defaultBranch: repo.defaultBranch, - isPrivate: repo.isPrivate, - url: repo.url, - }; - await this.plugin.saveSettings(); - this.plugin.updateStatusBar(); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions - this.display(); - } - } else { - this.plugin.settings.repo = null; - await this.plugin.saveSettings(); - this.plugin.updateStatusBar(); - } - }); - }); - } else { - repoSetting.addDropdown(dropdown => dropdown - .addOption('', 'Connect to GitHub first...') - .setDisabled(true)); - } - - if (this.plugin.settings.repo) { - new Setting(containerEl) - .setName('Branch') - .setDesc('Branch to sync with') - .addText(text => text - .setPlaceholder('Main') - .setValue(this.plugin.settings.repo?.branch ?? 'main') - .onChange(async (value) => { - if (this.plugin.settings.repo) { - this.plugin.settings.repo.branch = value || 'main'; - await this.plugin.saveSettings(); - } - })); - } - - new Setting(containerEl) - .setName('Subfolder path') - .setDesc('Optional: sync vault to a subfolder in the repo (e.g., "notes/Obsidian")') - .addText(text => text - .setPlaceholder('/') - .setValue(this.plugin.settings.subfolderPath) - .onChange(async (value) => { - this.plugin.settings.subfolderPath = value; - await this.plugin.saveSettings(); - })); + // ======================================================================== + // Declarative definitions + // ======================================================================== + + getSettingDefinitions(): SettingDefinitionItem[] { + return [ + ...this.authDefinitions(), + ...this.repoDefinitions(), + this.additionalReposDefinition(), + this.ignorePatternsDefinition(), + ...this.syncBehaviorDefinitions(), + ...this.commitDefinitions(), + ...this.conflictDefinitions(), + ...this.uiDefinitions(), + ...this.loggingDefinitions(), + ]; } - private renderAdditionalReposSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Additional repositories').setHeading(); - new Setting(containerEl) - .setDesc('Sync additional GitHub repositories into specific vault directories. Each repo is synced independently.'); + // ======================================================================== + // Authentication + // ======================================================================== + + private authDefinitions(): SettingDefinitionItem[] { + return [ + { + type: 'group', + heading: 'GitHub authentication', + items: [ + { + name: 'Connection status', + render: (setting) => { + const statusEl = setting.settingEl.createDiv({ cls: 'github-octokit-status' }); + this.updateConnectionStatus(statusEl); + setting.settingEl.prepend(statusEl); + setting.settingEl.querySelector('.setting-item-info')?.remove(); + setting.settingEl.querySelector('.setting-item-control')?.remove(); + }, + }, + { + name: 'Personal access token', + desc: 'Personal access token with repo access. Create one in your account developer settings.', + render: (setting) => { + setting + .addText(text => { + text + .setPlaceholder('Paste token here') + .setValue(this.plugin.settings.auth.token) + .onChange(async (value) => { + this.plugin.settings.auth.token = value; + this.plugin.settings.auth.tokenValidated = false; + await this.plugin.saveSettings(); + }); + text.inputEl.type = 'password'; + }) + .addButton(button => button + .setButtonText('Connect') + .setCta() + .onClick(async () => { + button.setButtonText('Connecting...'); + button.setDisabled(true); + + const success = await this.plugin.validateAndConnect(); + + if (success) { + new Notice(`Connected to GitHub as ${this.plugin.githubService.user?.login}`); + await this.loadRepositories(); + } else { + new Notice('Failed to connect to GitHub. Check your token.'); + } + + this.update(); + })); + }, + }, + ], + }, + ]; + } - // Render existing additional repos - for (const repoConfig of this.plugin.settings.additionalRepos) { - this.renderAdditionalRepoEntry(containerEl, repoConfig); - } + // ======================================================================== + // Repository + // ======================================================================== + + private repoDefinitions(): SettingDefinitionItem[] { + return [ + { + type: 'group', + heading: 'Repository', + items: [ + { + name: 'Repository', + desc: 'Select a repository to sync with', + render: (setting) => { + if (this.plugin.settings.auth.tokenValidated) { + setting.addDropdown(async dropdown => { + dropdown.addOption('', 'Select a repository...'); + + if (this.repositories.length === 0) { + await this.loadRepositories(); + } + + for (const repo of this.repositories) { + dropdown.addOption(repo.fullName, repo.fullName); + } + + dropdown.setValue(this.plugin.settings.repo + ? `${this.plugin.settings.repo.owner}/${this.plugin.settings.repo.name}` + : ''); + + dropdown.onChange(async (value) => { + if (value) { + const repo = this.repositories.find(r => r.fullName === value); + if (repo) { + this.plugin.settings.repo = { + owner: repo.owner, + name: repo.name, + branch: repo.defaultBranch, + defaultBranch: repo.defaultBranch, + isPrivate: repo.isPrivate, + url: repo.url, + }; + await this.plugin.saveSettings(); + this.plugin.updateStatusBar(); + this.update(); + } + } else { + this.plugin.settings.repo = null; + await this.plugin.saveSettings(); + this.plugin.updateStatusBar(); + } + }); + }); + } else { + setting.addDropdown(dropdown => dropdown + .addOption('', 'Connect to GitHub first...') + .setDisabled(true)); + } + }, + }, + { + name: 'Branch', + desc: 'Branch to sync with', + visible: () => this.plugin.settings.repo !== null, + render: (setting) => { + setting.addText(text => text + .setPlaceholder('Main') + .setValue(this.plugin.settings.repo?.branch ?? 'main') + .onChange(async (value) => { + if (this.plugin.settings.repo) { + this.plugin.settings.repo.branch = value || 'main'; + await this.plugin.saveSettings(); + } + })); + }, + }, + { + name: 'Subfolder path', + desc: 'Optional: sync vault to a subfolder in the repo (e.g., "notes/Obsidian")', + control: { type: 'text', key: 'subfolderPath', placeholder: '/' }, + }, + ], + }, + ]; + } - // Add new repo button - new Setting(containerEl) - .setName('Add repository') - .addButton(button => button - .setButtonText('Add') - .setCta() - .onClick(async () => { - const newRepo: AdditionalRepoConfig = { + // ======================================================================== + // Additional repositories + // ======================================================================== + + private additionalReposDefinition(): SettingDefinitionItem { + const repos = this.plugin.settings.additionalRepos; + + return { + type: 'list', + heading: 'Additional repositories', + cls: 'github-octokit-additional-repos', + emptyState: 'No additional repositories configured.', + addItem: { + name: 'Add repository', + action: () => { + repos.push({ id: this.generateId(), owner: '', repo: '', @@ -182,137 +261,142 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { subfolderPath: '', ignorePatterns: [], enabled: true, - }; - this.plugin.settings.additionalRepos.push(newRepo); - await this.plugin.saveSettings(); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions - this.display(); - })); + }); + void this.plugin.saveSettings().then(() => this.update()); + }, + }, + onDelete: (idx: number) => { + repos.splice(idx, 1); + void this.plugin.saveSettings().then(() => + this.plugin.initializeAdditionalRepos().then(() => this.update()), + ); + }, + items: repos.map((repoConfig) => ({ + type: 'page' as const, + name: repoConfig.owner && repoConfig.repo + ? `${repoConfig.owner}/${repoConfig.repo}` + : 'New repository', + desc: repoConfig.enabled ? 'Enabled' : 'Disabled', + items: this.additionalRepoPageItems(repoConfig), + })), + }; } - private renderAdditionalRepoEntry(containerEl: HTMLElement, repoConfig: AdditionalRepoConfig): void { - const repoContainer = containerEl.createDiv({ cls: 'github-octokit-additional-repo' }); - const index = this.plugin.settings.additionalRepos.indexOf(repoConfig); - - // Header with repo name and controls - const headerLabel = repoConfig.owner && repoConfig.repo - ? `${repoConfig.owner}/${repoConfig.repo}` - : 'New repository'; - - new Setting(repoContainer) - .setName(headerLabel) - .addToggle(toggle => toggle - .setValue(repoConfig.enabled) - .setTooltip('Enable or disable this repository') - .onChange(async (value) => { - repoConfig.enabled = value; - await this.plugin.saveSettings(); - await this.plugin.initializeAdditionalRepos(); - })) - .addButton(button => button - .setIcon('trash') - .setTooltip('Remove repository') - .onClick(async () => { - this.plugin.settings.additionalRepos.splice(index, 1); - await this.plugin.saveSettings(); - await this.plugin.initializeAdditionalRepos(); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions - this.display(); - })); - - // Owner - new Setting(repoContainer) - .setName('Owner') - .setDesc('GitHub user or organization') - .addText(text => text - .setPlaceholder('Owner') - .setValue(repoConfig.owner) - .onChange(async (value) => { - repoConfig.owner = value.trim(); - await this.plugin.saveSettings(); - })); - - // Repo name - new Setting(repoContainer) - .setName('Repository name') - .addText(text => text - .setPlaceholder('Repo name') - .setValue(repoConfig.repo) - .onChange(async (value) => { - repoConfig.repo = value.trim(); - await this.plugin.saveSettings(); - })); - - // Branch - new Setting(repoContainer) - .setName('Branch') - .addText(text => text - .setPlaceholder('Main') - .setValue(repoConfig.branch) - .onChange(async (value) => { - repoConfig.branch = value.trim() || 'main'; - await this.plugin.saveSettings(); - })); - - // Local path - new Setting(repoContainer) - .setName('Vault directory') - .setDesc('Directory in the vault to sync this repo into') - .addText(text => text - .setPlaceholder('My other repo') - .setValue(repoConfig.localPath) - .onChange(async (value) => { - const trimmed = value.trim(); - // Validate no overlap with other repos - const overlap = this.validateLocalPath(trimmed, repoConfig.id); - if (overlap) { - new Notice(overlap); - return; - } - repoConfig.localPath = trimmed; - await this.plugin.saveSettings(); - await this.plugin.initializeAdditionalRepos(); - })); - - // Token settings - new Setting(repoContainer) - .setName('Use main token') - .setDesc('Use the same token as the main repository') - .addToggle(toggle => toggle - .setValue(repoConfig.useMainToken) - .onChange(async (value) => { - repoConfig.useMainToken = value; - await this.plugin.saveSettings(); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions - this.display(); - })); - - if (!repoConfig.useMainToken) { - new Setting(repoContainer) - .setName('Personal access token') - .addText(text => { - text.inputEl.type = 'password'; - text - .setPlaceholder('Paste token here') - .setValue(repoConfig.token) + private additionalRepoPageItems(repoConfig: AdditionalRepoConfig): SettingDefinitionItem[] { + return [ + { + name: 'Enabled', + desc: 'Enable or disable this repository', + render: (setting) => { + setting.addToggle(toggle => toggle + .setValue(repoConfig.enabled) .onChange(async (value) => { - repoConfig.token = value; + repoConfig.enabled = value; await this.plugin.saveSettings(); - }); - }); - } - - // Subfolder path - new Setting(repoContainer) - .setName('Subfolder path') - .setDesc('Optional: sync a subfolder of the remote repo') - .addText(text => text - .setPlaceholder('E.g., docs/notes') - .setValue(repoConfig.subfolderPath) - .onChange(async (value) => { - repoConfig.subfolderPath = value.trim(); - await this.plugin.saveSettings(); - })); + await this.plugin.initializeAdditionalRepos(); + })); + }, + }, + { + name: 'Owner', + desc: 'GitHub user or organization', + render: (setting) => { + setting.addText(text => text + .setPlaceholder('Owner') + .setValue(repoConfig.owner) + .onChange(async (value) => { + repoConfig.owner = value.trim(); + await this.plugin.saveSettings(); + })); + }, + }, + { + name: 'Repository name', + render: (setting) => { + setting.addText(text => text + .setPlaceholder('Repo name') + .setValue(repoConfig.repo) + .onChange(async (value) => { + repoConfig.repo = value.trim(); + await this.plugin.saveSettings(); + })); + }, + }, + { + name: 'Branch', + render: (setting) => { + setting.addText(text => text + .setPlaceholder('Main') + .setValue(repoConfig.branch) + .onChange(async (value) => { + repoConfig.branch = value.trim() || 'main'; + await this.plugin.saveSettings(); + })); + }, + }, + { + name: 'Vault directory', + desc: 'Directory in the vault to sync this repo into', + render: (setting) => { + setting.addText(text => text + .setPlaceholder('My other repo') + .setValue(repoConfig.localPath) + .onChange(async (value) => { + const trimmed = value.trim(); + const overlap = this.validateLocalPath(trimmed, repoConfig.id); + if (overlap) { + new Notice(overlap); + return; + } + repoConfig.localPath = trimmed; + await this.plugin.saveSettings(); + await this.plugin.initializeAdditionalRepos(); + })); + }, + }, + { + name: 'Use main token', + desc: 'Use the same token as the main repository', + render: (setting) => { + setting.addToggle(toggle => toggle + .setValue(repoConfig.useMainToken) + .onChange(async (value) => { + repoConfig.useMainToken = value; + await this.plugin.saveSettings(); + this.update(); + })); + }, + }, + { + name: 'Personal access token', + visible: () => !repoConfig.useMainToken, + render: (setting) => { + setting.addText(text => { + text.inputEl.type = 'password'; + text + .setPlaceholder('Paste token here') + .setValue(repoConfig.token) + .onChange(async (value) => { + repoConfig.token = value; + await this.plugin.saveSettings(); + }); + }); + }, + }, + { + name: 'Subfolder path', + desc: 'Optional: sync a subfolder of the remote repo', + render: (setting) => { + setting.addText(text => text + .setPlaceholder('E.g., docs/notes') + .setValue(repoConfig.subfolderPath) + .onChange(async (value) => { + repoConfig.subfolderPath = value.trim(); + await this.plugin.saveSettings(); + })); + }, + }, + ]; } /** @@ -345,257 +429,297 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { return Date.now().toString(36) + Math.random().toString(36).substring(2, 9); } - private renderIgnorePatternsSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Ignore patterns').setHeading(); - new Setting(containerEl) - .setDesc(`Files matching these patterns will be excluded from sync. Use glob patterns (e.g., "*.tmp", "${this.plugin.app.vault.configDir}/workspace.json").`); - - const patternsContainer = containerEl.createDiv({ cls: 'github-octokit-ignore-patterns' }); - - this.plugin.settings.ignorePatterns.forEach((pattern, index) => { - new Setting(patternsContainer) - .setName(pattern) - .addButton(button => button - .setIcon('trash') - .setTooltip('Remove pattern') - .onClick(async () => { - this.plugin.settings.ignorePatterns.splice(index, 1); - await this.plugin.saveSettings(); - this.plugin.syncService.configure( - this.plugin.getMainRepoIgnorePatterns(), - this.plugin.settings.subfolderPath, - this.plugin.settings.syncConfiguration - ); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions - this.display(); - })); - }); - - new Setting(containerEl) - .setName('Add pattern') - .setDesc('Add a new ignore pattern') - .addText(text => text - .setPlaceholder(`${this.plugin.app.vault.configDir}/cache/**`) - .onChange(() => {})) - .addButton(button => button - .setButtonText('Add') - .onClick(async () => { - const input = containerEl.querySelector('.github-octokit-ignore-patterns + .setting-item input') as HTMLInputElement; - const value = input?.value?.trim(); - if (value && !this.plugin.settings.ignorePatterns.includes(value)) { - this.plugin.settings.ignorePatterns.push(value); - await this.plugin.saveSettings(); - this.plugin.syncService.configure( - this.plugin.getMainRepoIgnorePatterns(), - this.plugin.settings.subfolderPath, - this.plugin.settings.syncConfiguration - ); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions - this.display(); - } - })); - } - - private renderSyncTriggersSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Sync behavior').setHeading(); - - const configDir = this.plugin.app.vault.configDir; - new Setting(containerEl) - .setName('Sync configuration folder') - .setDesc(`Include the ${configDir} folder in sync. When disabled, all configuration files are excluded.`) - .addToggle(toggle => toggle - .setValue(this.plugin.settings.syncConfiguration) - .onChange(async (value) => { - this.plugin.settings.syncConfiguration = value; - await this.plugin.saveSettings(); + // ======================================================================== + // Ignore patterns + // ======================================================================== + + private ignorePatternsDefinition(): SettingDefinitionItem { + const patterns = this.plugin.settings.ignorePatterns; + + return { + type: 'list', + heading: 'Ignore patterns', + cls: 'github-octokit-ignore-patterns', + emptyState: 'No ignore patterns configured.', + addItem: { + name: 'Add pattern', + action: () => { + patterns.push(''); + void this.plugin.saveSettings().then(() => this.update()); + }, + }, + onDelete: (idx: number) => { + patterns.splice(idx, 1); + void this.plugin.saveSettings().then(() => { this.plugin.syncService.configure( this.plugin.getMainRepoIgnorePatterns(), this.plugin.settings.subfolderPath, this.plugin.settings.syncConfiguration ); - })); - - new Setting(containerEl) - .setDesc('Choose when the plugin should automatically sync with GitHub. Enable multiple triggers as needed.'); - - new Setting(containerEl) - .setName('Sync on file save') - .setDesc('Automatically sync when you save a file') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.syncSchedule.syncOnSave) - .onChange(async (value) => { - this.plugin.settings.syncSchedule.syncOnSave = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Sync on interval') - .setDesc('Automatically sync at regular intervals') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.syncSchedule.syncOnInterval) - .onChange(async (value) => { - this.plugin.settings.syncSchedule.syncOnInterval = value; - await this.plugin.saveSettings(); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- TODO: migrate to getSettingDefinitions - this.display(); - })); - - if (this.plugin.settings.syncSchedule.syncOnInterval) { - new Setting(containerEl) - .setName('Sync interval (minutes)') - .setDesc('How often to sync with GitHub') - .addSlider(slider => slider - .setLimits(5, 120, 5) - .setValue(this.plugin.settings.syncSchedule.intervalMinutes) - .setDynamicTooltip() - .onChange(async (value) => { - this.plugin.settings.syncSchedule.intervalMinutes = value; - await this.plugin.saveSettings(); - })); - } - - new Setting(containerEl) - .setName('Sync on startup') - .setDesc('Automatically sync when Obsidian starts') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.syncSchedule.syncOnStartup) - .onChange(async (value) => { - this.plugin.settings.syncSchedule.syncOnStartup = value; - await this.plugin.saveSettings(); - })); + this.update(); + }); + }, + items: patterns.map((pattern, idx) => ({ + name: pattern || 'New pattern', + render: (setting: Setting) => { + setting.addText(text => text + .setPlaceholder(`${this.plugin.app.vault.configDir}/cache/**`) + .setValue(pattern) + .onChange(async (value) => { + patterns[idx] = value.trim(); + await this.plugin.saveSettings(); + this.plugin.syncService.configure( + this.plugin.getMainRepoIgnorePatterns(), + this.plugin.settings.subfolderPath, + this.plugin.settings.syncConfiguration + ); + })); + }, + })), + }; } - private renderCommitSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Commit messages').setHeading(); - - new Setting(containerEl) - .setName('Commit message template') - .setDesc('Template for commit messages. Use {date}, {files}, {action}') - .addText(text => text - .setPlaceholder('Vault sync: {date}') - .setValue(this.plugin.settings.commitConfig.messageTemplate) - .onChange(async (value) => { - this.plugin.settings.commitConfig.messageTemplate = value; - await this.plugin.saveSettings(); - })); + // ======================================================================== + // Sync behavior + // ======================================================================== + + private syncBehaviorDefinitions(): SettingDefinitionItem[] { + return [ + { + type: 'group', + heading: 'Sync behavior', + items: [ + { + name: 'Sync configuration folder', + desc: `Include the ${this.plugin.app.vault.configDir} folder in sync. When disabled, all configuration files are excluded.`, + render: (setting) => { + setting.addToggle(toggle => toggle + .setValue(this.plugin.settings.syncConfiguration) + .onChange(async (value) => { + this.plugin.settings.syncConfiguration = value; + await this.plugin.saveSettings(); + this.plugin.syncService.configure( + this.plugin.getMainRepoIgnorePatterns(), + this.plugin.settings.subfolderPath, + this.plugin.settings.syncConfiguration + ); + })); + }, + }, + { + name: 'Sync on file save', + desc: 'Automatically sync when you save a file', + control: { type: 'toggle', key: 'syncSchedule.syncOnSave' }, + }, + { + name: 'Sync on interval', + desc: 'Automatically sync at regular intervals', + render: (setting) => { + setting.addToggle(toggle => toggle + .setValue(this.plugin.settings.syncSchedule.syncOnInterval) + .onChange(async (value) => { + this.plugin.settings.syncSchedule.syncOnInterval = value; + await this.plugin.saveSettings(); + this.update(); + })); + }, + }, + { + name: 'Sync interval (minutes)', + desc: 'How often to sync with GitHub', + visible: () => this.plugin.settings.syncSchedule.syncOnInterval, + control: { + type: 'slider', + key: 'syncSchedule.intervalMinutes', + min: 5, + max: 120, + step: 5, + }, + }, + { + name: 'Sync on startup', + desc: 'Automatically sync when Obsidian starts', + control: { type: 'toggle', key: 'syncSchedule.syncOnStartup' }, + }, + ], + }, + ]; } - private renderConflictSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Conflict resolution').setHeading(); - - new Setting(containerEl) - .setName('Default resolution') - .setDesc('How to handle conflicts when the same file is changed locally and remotely') - .addDropdown(dropdown => dropdown - .addOption('manual', 'Ask me each time') - .addOption('keep-local', 'Keep local version') - .addOption('keep-remote', 'Keep remote version') - .addOption('keep-both', 'Keep both (rename)') - .setValue(this.plugin.settings.defaultConflictResolution) - .onChange(async (value: string) => { - this.plugin.settings.defaultConflictResolution = value as ConflictResolution; - await this.plugin.saveSettings(); - })); + // ======================================================================== + // Commit messages + // ======================================================================== + + private commitDefinitions(): SettingDefinitionItem[] { + return [ + { + type: 'group', + heading: 'Commit messages', + items: [ + { + name: 'Commit message template', + desc: 'Template for commit messages. Use {date}, {files}, {action}', + control: { + type: 'text', + key: 'commitConfig.messageTemplate', + placeholder: 'Vault sync: {date}', + }, + }, + ], + }, + ]; } - private renderUISection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('UI preferences').setHeading(); - - new Setting(containerEl) - .setName('Show status bar') - .setDesc('Show sync status in the status bar') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.showStatusBar) - .onChange(async (value) => { - this.plugin.settings.showStatusBar = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Show notifications') - .setDesc('Show notifications for sync events') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.showNotifications) - .onChange(async (value) => { - this.plugin.settings.showNotifications = value; - await this.plugin.saveSettings(); - })); + // ======================================================================== + // Conflict resolution + // ======================================================================== + + private conflictDefinitions(): SettingDefinitionItem[] { + return [ + { + type: 'group', + heading: 'Conflict resolution', + items: [ + { + name: 'Default resolution', + desc: 'How to handle conflicts when the same file is changed locally and remotely', + control: { + type: 'dropdown', + key: 'defaultConflictResolution', + options: { + 'manual': 'Ask me each time', + 'keep-local': 'Keep local version', + 'keep-remote': 'Keep remote version', + 'keep-both': 'Keep both (rename)', + }, + }, + }, + ], + }, + ]; } - private renderLoggingSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Logging').setHeading(); - - new Setting(containerEl) - .setName('Enable logging') - .setDesc('Log sync operations for debugging') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.logging.enabled) - .onChange(async (value) => { - this.plugin.settings.logging.enabled = value; - this.plugin.logger.configure({ enabled: value }); - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Log level') - .setDesc('Minimum log level to record') - .addDropdown(dropdown => dropdown - .addOption('debug', 'Debug (verbose)') - .addOption('info', 'Info (normal)') - .addOption('warn', 'Warnings only') - .addOption('error', 'Errors only') - .setValue(this.plugin.settings.logging.level) - .onChange(async (value: string) => { - this.plugin.settings.logging.level = value as LogLevel; - this.plugin.logger.configure({ level: value as LogLevel }); - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Persist logs to file') - .setDesc('Save logs to a file in your vault') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.logging.persistToFile) - .onChange(async (value) => { - this.plugin.settings.logging.persistToFile = value; - this.plugin.logger.configure({ persistToFile: value }); - await this.plugin.saveSettings(); - })); - - if (this.plugin.settings.logging.persistToFile) { - new Setting(containerEl) - .setName('Log file path') - .setDesc('Path for the log file (relative to vault root)') - .addText(text => text - .setPlaceholder('Enter log file path') - .setValue(this.plugin.settings.logging.logFilePath) - .onChange(async (value) => { - this.plugin.settings.logging.logFilePath = value || '.github-sync.log'; - this.plugin.logger.configure({ logFilePath: value || '.github-sync.log' }); - await this.plugin.saveSettings(); - })); - } + // ======================================================================== + // UI preferences + // ======================================================================== + + private uiDefinitions(): SettingDefinitionItem[] { + return [ + { + type: 'group', + heading: 'UI preferences', + items: [ + { + name: 'Show status bar', + desc: 'Show sync status in the status bar', + control: { type: 'toggle', key: 'showStatusBar' }, + }, + { + name: 'Show notifications', + desc: 'Show notifications for sync events', + control: { type: 'toggle', key: 'showNotifications' }, + }, + ], + }, + ]; + } - new Setting(containerEl) - .setName('View logs') - .setDesc('View recent log entries') - .addButton(button => button - .setButtonText('View logs') - .onClick(() => { - new LogViewerModal(this.app, this.plugin.logger).open(); - })); - - new Setting(containerEl) - .setName('Clear logs') - .setDesc('Clear all log entries from memory') - .addButton(button => button - .setButtonText('Clear') - // eslint-disable-next-line @typescript-eslint/no-deprecated -- setDestructive requires minAppVersion 1.13.0 - .setWarning() - .onClick(() => { - this.plugin.logger.clear(); - new Notice('Logs cleared'); - })); + // ======================================================================== + // Logging + // ======================================================================== + + private loggingDefinitions(): SettingDefinitionItem[] { + return [ + { + type: 'group', + heading: 'Logging', + items: [ + { + name: 'Enable logging', + desc: 'Log sync operations for debugging', + render: (setting) => { + setting.addToggle(toggle => toggle + .setValue(this.plugin.settings.logging.enabled) + .onChange(async (value) => { + this.plugin.settings.logging.enabled = value; + this.plugin.logger.configure({ enabled: value }); + await this.plugin.saveSettings(); + })); + }, + }, + { + name: 'Log level', + desc: 'Minimum log level to record', + render: (setting) => { + setting.addDropdown(dropdown => dropdown + .addOption('debug', 'Debug (verbose)') + .addOption('info', 'Info (normal)') + .addOption('warn', 'Warnings only') + .addOption('error', 'Errors only') + .setValue(this.plugin.settings.logging.level) + .onChange(async (value: string) => { + this.plugin.settings.logging.level = value as LogLevel; + this.plugin.logger.configure({ level: value as LogLevel }); + await this.plugin.saveSettings(); + })); + }, + }, + { + name: 'Persist logs to file', + desc: 'Save logs to a file in your vault', + render: (setting) => { + setting.addToggle(toggle => toggle + .setValue(this.plugin.settings.logging.persistToFile) + .onChange(async (value) => { + this.plugin.settings.logging.persistToFile = value; + this.plugin.logger.configure({ persistToFile: value }); + await this.plugin.saveSettings(); + this.update(); + })); + }, + }, + { + name: 'Log file path', + desc: 'Path for the log file (relative to vault root)', + visible: () => this.plugin.settings.logging.persistToFile, + render: (setting) => { + setting.addText(text => text + .setPlaceholder('Enter log file path') + .setValue(this.plugin.settings.logging.logFilePath) + .onChange(async (value) => { + this.plugin.settings.logging.logFilePath = value || '.github-sync.log'; + this.plugin.logger.configure({ logFilePath: value || '.github-sync.log' }); + await this.plugin.saveSettings(); + })); + }, + }, + { + name: 'View logs', + desc: 'View recent log entries', + render: (setting) => { + setting.addButton(button => button + .setButtonText('View logs') + .onClick(() => { + new LogViewerModal(this.app, this.plugin.logger).open(); + })); + }, + }, + { + name: 'Clear logs', + desc: 'Clear all log entries from memory', + render: (setting) => { + setting.addButton(button => button + .setButtonText('Clear') + .setDestructive() + .onClick(() => { + this.plugin.logger.clear(); + new Notice('Logs cleared'); + })); + }, + }, + ], + }, + ]; } private updateConnectionStatus(containerEl: HTMLElement): void { diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 1c3ac2d..00b9deb 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -159,8 +159,14 @@ export class PluginSettingTab { constructor(public app: App, public plugin: Plugin) {} display(): void {} hide(): void {} + getSettingDefinitions(): any[] { return []; } + getControlValue(_key: string): unknown { return undefined; } + setControlValue(_key: string, _value: unknown): void {} + update(): void {} } +export type SettingDefinitionItem = any; + export class Setting { constructor(public containerEl: HTMLElement) {} setName(_name: string): this { return this; } diff --git a/versions.json b/versions.json index 7da04d4..457c074 100644 --- a/versions.json +++ b/versions.json @@ -4,5 +4,5 @@ "0.4.1": "1.7.2", "0.5.0": "1.12.7", "0.5.1": "1.12.7", - "0.6.0": "1.12.7" + "0.6.0": "1.13.0" } From ec41f6e402e740915bf5c08aa07652ff576ef90e Mon Sep 17 00:00:00 2001 From: Mark Rhoades-Brown Date: Fri, 29 May 2026 23:42:39 +0100 Subject: [PATCH 2/2] feat: add 1.13.0 API enhancements --- README.md | 8 +++++- main.ts | 11 +-------- src/ui/modals/SyncModal.ts | 41 +++++++++++++++++++------------ src/ui/modals/index.ts | 1 + src/ui/settingsTab.ts | 50 +++++++++++++++++++++++++------------- src/views/SyncView.ts | 17 ++++++++++--- tests/mocks/obsidian.ts | 23 ++++++++++++++++++ 7 files changed, 103 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 59eda98..96a0ce0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Sync your Obsidian vault with GitHub using the official Octokit API — no Git C - **Shared config across devices** — additional repo configurations are stored in `.github-sync-repos.json` inside the vault, so they are synced with the primary repo and automatically picked up on other devices - **Per-repo tokens** — use the main token or a separate PAT for each additional repo +### Settings + +- **Declarative settings UI** — built on Obsidian's 1.13.0 declarative Settings API with searchable, keyboard-navigable settings +- **Inline validation** — connection failures and path overlaps shown as inline error messages directly on the setting +- **Confirmation dialogs** — destructive actions (deleting repos, clearing logs) prompt for confirmation via native `ConfirmationModal` + ### Security - **Encrypted token storage** — all GitHub tokens (main and per-repo) are stored in Obsidian's encrypted `SecretStorage`, never in plaintext `data.json` @@ -52,7 +58,7 @@ Sync your Obsidian vault with GitHub using the official Octokit API — no Git C ## Prerequisites -- Obsidian v1.12.7 or later +- Obsidian v1.13.0 or later - A GitHub account with a Personal Access Token ## Installation diff --git a/main.ts b/main.ts index 274bc5d..25ff294 100644 --- a/main.ts +++ b/main.ts @@ -4,7 +4,7 @@ import { SyncService, PersistedSyncState, SyncResult } from './src/services/sync import { LoggerService } from './src/services/loggerService'; import { DiffView, DIFF_VIEW_TYPE } from './src/views/DiffView'; import { SyncView, SYNC_VIEW_TYPE } from './src/views/SyncView'; -import { GitHubOctokitSettingTab, SyncModal } from './src/ui'; +import { GitHubOctokitSettingTab } from './src/ui'; import { GitHubOctokitSettings, DEFAULT_SETTINGS, AdditionalRepoConfig, VaultRepoConfig, VAULT_REPOS_CONFIG_PATH } from './src/types/settings'; /** Per-repo runtime state for additional repositories */ @@ -149,15 +149,6 @@ export default class GitHubOctokitPlugin extends Plugin { } }); - // Command: Open sync modal - this.addCommand({ - id: 'open-sync-modal', - name: 'Open sync modal', - callback: () => { - new SyncModal(this.app).open(); - } - }); - // Command: Open diff view this.addCommand({ id: 'open-diff-view', diff --git a/src/ui/modals/SyncModal.ts b/src/ui/modals/SyncModal.ts index 5ef76b2..0388428 100644 --- a/src/ui/modals/SyncModal.ts +++ b/src/ui/modals/SyncModal.ts @@ -1,21 +1,30 @@ -import { App, Modal } from 'obsidian'; +import { App, ConfirmationModal } from 'obsidian'; /** - * Simple sync modal (placeholder for future enhancements) + * Show a confirmation dialog before a destructive action. + * + * @param app The Obsidian App instance + * @param title Heading text shown in the modal + * @param message Body text describing the consequences + * @param confirmText Label for the confirm button (default: "Delete") + * @param onConfirm Callback invoked when the user confirms */ -export class SyncModal extends Modal { - constructor(app: App) { - super(app); - } - - onOpen() { - const { contentEl } = this; - contentEl.setText('Sync in progress'); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } +export function confirmDestructiveAction( + app: App, + title: string, + message: string, + confirmText: string, + onConfirm: () => void, +): void { + const modal = new ConfirmationModal(app); + modal.titleEl.setText(title); + modal.contentEl.createEl('p', { text: message }); + modal.addButton(btn => btn + .setButtonText(confirmText) + .setDestructive() + .setCta() + .onClick(onConfirm)); + modal.addCancelButton(); + modal.open(); } diff --git a/src/ui/modals/index.ts b/src/ui/modals/index.ts index 4625786..b439dd0 100644 --- a/src/ui/modals/index.ts +++ b/src/ui/modals/index.ts @@ -1,3 +1,4 @@ export * from './LogViewerModal'; export * from './SyncModal'; + diff --git a/src/ui/settingsTab.ts b/src/ui/settingsTab.ts index b9425f5..f32d56d 100644 --- a/src/ui/settingsTab.ts +++ b/src/ui/settingsTab.ts @@ -4,6 +4,7 @@ import type { GitHubRepo } from '../services/githubService'; import { LogLevel } from '../services/loggerService'; import { AdditionalRepoConfig } from '../types/settings'; import { LogViewerModal } from './modals/LogViewerModal'; +import { confirmDestructiveAction } from './modals/SyncModal'; import type GitHubOctokitPlugin from '../../main'; // ============================================================================ @@ -67,7 +68,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { key, value, ); - await this.plugin.saveData(this.plugin.settings); + await this.plugin.saveSettings(); } // ======================================================================== @@ -120,6 +121,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { .onChange(async (value) => { this.plugin.settings.auth.token = value; this.plugin.settings.auth.tokenValidated = false; + setting.setErrorMessage(null); await this.plugin.saveSettings(); }); text.inputEl.type = 'password'; @@ -130,6 +132,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { .onClick(async () => { button.setButtonText('Connecting...'); button.setDisabled(true); + setting.setErrorMessage(null); const success = await this.plugin.validateAndConnect(); @@ -137,7 +140,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { new Notice(`Connected to GitHub as ${this.plugin.githubService.user?.login}`); await this.loadRepositories(); } else { - new Notice('Failed to connect to GitHub. Check your token.'); + setting.setErrorMessage('Failed to connect. Check your token and try again.'); } this.update(); @@ -266,9 +269,21 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { }, }, onDelete: (idx: number) => { - repos.splice(idx, 1); - void this.plugin.saveSettings().then(() => - this.plugin.initializeAdditionalRepos().then(() => this.update()), + const repo = repos[idx]; + const label = repo.owner && repo.repo + ? `${repo.owner}/${repo.repo}` + : 'this repository'; + confirmDestructiveAction( + this.app, + 'Remove repository', + `Are you sure you want to remove ${label}? This cannot be undone.`, + 'Remove', + () => { + repos.splice(idx, 1); + void this.plugin.saveSettings().then(() => + this.plugin.initializeAdditionalRepos().then(() => this.update()), + ); + }, ); }, items: repos.map((repoConfig) => ({ @@ -345,9 +360,10 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { const trimmed = value.trim(); const overlap = this.validateLocalPath(trimmed, repoConfig.id); if (overlap) { - new Notice(overlap); + setting.setErrorMessage(overlap); return; } + setting.setErrorMessage(null); repoConfig.localPath = trimmed; await this.plugin.saveSettings(); await this.plugin.initializeAdditionalRepos(); @@ -514,15 +530,7 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { { name: 'Sync on interval', desc: 'Automatically sync at regular intervals', - render: (setting) => { - setting.addToggle(toggle => toggle - .setValue(this.plugin.settings.syncSchedule.syncOnInterval) - .onChange(async (value) => { - this.plugin.settings.syncSchedule.syncOnInterval = value; - await this.plugin.saveSettings(); - this.update(); - })); - }, + control: { type: 'toggle', key: 'syncSchedule.syncOnInterval' }, }, { name: 'Sync interval (minutes)', @@ -712,8 +720,16 @@ export class GitHubOctokitSettingTab extends PluginSettingTab { .setButtonText('Clear') .setDestructive() .onClick(() => { - this.plugin.logger.clear(); - new Notice('Logs cleared'); + confirmDestructiveAction( + this.app, + 'Clear logs', + 'Are you sure you want to clear all log entries? This cannot be undone.', + 'Clear', + () => { + this.plugin.logger.clear(); + new Notice('Logs cleared'); + }, + ); })); }, }, diff --git a/src/views/SyncView.ts b/src/views/SyncView.ts index 5b56c6a..6467ae9 100644 --- a/src/views/SyncView.ts +++ b/src/views/SyncView.ts @@ -2,6 +2,7 @@ import { ItemView, WorkspaceLeaf, Notice, TFile } from 'obsidian'; import type GitHubOctokitPlugin from '../../main'; import { FileSyncState } from '../services/syncService'; import { LogEntry } from '../services/loggerService'; +import { confirmDestructiveAction } from '../ui/modals/SyncModal'; export const SYNC_VIEW_TYPE = 'github-octokit-sync-view'; @@ -520,10 +521,18 @@ export class SyncView extends ItemView { const clearBtn = controls.createEl('button', { text: 'Clear', cls: 'logs-clear-btn' }); clearBtn.addEventListener('click', (e) => { e.stopPropagation(); - this.plugin.logger.clear(); - if (this.logsExpanded) { - this.renderLogEntries(logsContainer, 'all'); - } + confirmDestructiveAction( + this.plugin.app, + 'Clear logs', + 'Are you sure you want to clear all log entries? This cannot be undone.', + 'Clear', + () => { + this.plugin.logger.clear(); + if (this.logsExpanded) { + this.renderLogEntries(logsContainer, 'all'); + } + }, + ); }); if (!this.logsExpanded) { diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 00b9deb..83b60eb 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -168,9 +168,12 @@ export class PluginSettingTab { export type SettingDefinitionItem = any; export class Setting { + settingEl: HTMLElement = document.createElement('div'); + errorEl: HTMLElement | null = null; constructor(public containerEl: HTMLElement) {} setName(_name: string): this { return this; } setDesc(_desc: string): this { return this; } + setErrorMessage(_message: string | null): this { return this; } addText(_cb: Function): this { return this; } addTextArea(_cb: Function): this { return this; } addToggle(_cb: Function): this { return this; } @@ -181,6 +184,7 @@ export class Setting { export class Modal { contentEl: HTMLElement = document.createElement('div'); + titleEl: HTMLElement = document.createElement('div'); constructor(public app: App) {} open(): void {} close(): void {} @@ -188,3 +192,22 @@ export class Modal { onClose(): void {} } +export class ButtonComponent { + setButtonText(_name: string): this { return this; } + setCta(): this { return this; } + setDestructive(): this { return this; } + removeDestructive(): this { return this; } + setIcon(_icon: string): this { return this; } + setTooltip(_tooltip: string): this { return this; } + onClick(_cb: (evt: MouseEvent) => unknown): this { return this; } +} + +export class ConfirmationModal extends Modal { + buttonContainerEl: HTMLElement = document.createElement('div'); + constructor(app: App) { super(app); } + addClass(_cls: string): ConfirmationModal { return this; } + addCheckbox(_label: string, _cb: (value: boolean) => unknown): ConfirmationModal { return this; } + addButton(_cb: (btn: ButtonComponent) => unknown): ConfirmationModal { return this; } + addCancelButton(_text?: string): ConfirmationModal { return this; } +} +