From a628b1f8ce1a078f5e94b609534a1833dfa84726 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 2 Jan 2025 22:06:19 +0000 Subject: [PATCH] Improve settings --- plugin/src/plugin.ts | 50 +++-- plugin/src/styles.scss | 106 +++++++++-- plugin/src/views/settings-tab.ts | 246 +++++++++++++++++-------- plugin/src/views/status-description.ts | 139 ++++++++++++++ 4 files changed, 437 insertions(+), 104 deletions(-) create mode 100644 plugin/src/views/status-description.ts diff --git a/plugin/src/plugin.ts b/plugin/src/plugin.ts index 520288e..afd3f63 100644 --- a/plugin/src/plugin.ts +++ b/plugin/src/plugin.ts @@ -16,12 +16,13 @@ import { Logger } from "./tracing/logger.js"; import { SyncHistory } from "./tracing/sync-history.js"; import { LogsView } from "./views/logs-view.js"; import { Syncer } from "./sync-operations/syncer.js"; +import { StatusDescription } from "./views/status-description.js"; export default class SyncPlugin extends Plugin { - private remoteListenerIntervalId: number | null = null; private readonly operations = new ObsidianFileOperations(this.app.vault); private readonly history = new SyncHistory(); private settingsTab: SyncSettingsTab; + private remoteListenerIntervalId: number | null = null; public async onload(): Promise { Logger.getInstance().info("Starting plugin"); @@ -40,22 +41,30 @@ export default class SyncPlugin extends Plugin { this.saveData.bind(this) ); - const syncServer = new SyncService(database); + const syncService = new SyncService(database); const syncer = new Syncer({ database, operations: this.operations, - syncServer, + syncService, history: this.history, }); - this.settingsTab = new SyncSettingsTab( - this.app, - this, + const statusDescription = new StatusDescription( database, - syncServer, + syncService, + this.history, syncer ); + + this.settingsTab = new SyncSettingsTab({ + app: this.app, + plugin: this, + database, + syncService, + statusDescription, + syncer, + }); this.addSettingTab(this.settingsTab); new StatusBar(database, this, this.history, syncer); @@ -86,14 +95,14 @@ export default class SyncPlugin extends Plugin { this.registerEvent(event); }); - await syncer.scheduleSyncForOfflineChanges(); - Logger.getInstance().info("Sync handlers initialised"); + + void syncer.scheduleSyncForOfflineChanges(); }); this.registerRemoteEventListener( database, - syncServer, + syncService, syncer, database.getSettings().fetchChangesUpdateIntervalMs ); @@ -102,7 +111,7 @@ export default class SyncPlugin extends Plugin { database.addOnSettingsChangeHandlers(async (settings, oldSettings) => { this.registerRemoteEventListener( database, - syncServer, + syncService, syncer, settings.fetchChangesUpdateIntervalMs ); @@ -130,6 +139,8 @@ export default class SyncPlugin extends Plugin { ); Logger.getInstance().info("Plugin loaded"); + + this.openSettings(); } public onunload(): void { @@ -138,14 +149,19 @@ export default class SyncPlugin extends Plugin { } } - public openSettings() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + public openSettings(): void { + // eslint-disable-next-line (this.app as any).setting.open(); // this is undocumented - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line (this.app as any).setting.openTab(this.settingsTab); // this is undocumented } - private async activateView(type: string): Promise { + public closeSettings(): void { + // eslint-disable-next-line + (this.app as any).setting.close(); // this is undocumented + } + + public async activateView(type: string): Promise { const { workspace } = this.app; let leaf: WorkspaceLeaf | null = null; @@ -165,7 +181,7 @@ export default class SyncPlugin extends Plugin { private registerRemoteEventListener( database: Database, - syncServer: SyncService, + syncService: SyncService, syncer: Syncer, intervalMs: number ): void { @@ -178,7 +194,7 @@ export default class SyncPlugin extends Plugin { async () => applyRemoteChangesLocally({ database, - syncServer, + syncService, syncer, }), intervalMs diff --git a/plugin/src/styles.scss b/plugin/src/styles.scss index 78934cd..6392847 100644 --- a/plugin/src/styles.scss +++ b/plugin/src/styles.scss @@ -1,31 +1,106 @@ -.sync-settings-access-token textarea { - width: 100%; - height: 100px; +.status-description { + margin: var(--p-spacing) 0; + + .number { + padding: var(--size-2-1) var(--size-4-1); + border-radius: var(--radius-s); + background-color: var(--color-base-30); + font-family: var(--font-monospace-default); + font-weight: var(--bold-weight); + + &.good { + background-color: rgba(var(--color-green-rgb), 0.35); + } + + &.bad { + background-color: rgba(var(--color-red-rgb), 0.35); + } + } + + .error { + color: rgb(var(--color-red-rgb)); + } + + .warning { + color: rgb(var(--color-yellow-rgb)); + } +} + +.vault-link-settings { + h2 { + display: flex; + align-items: center; + font-size: var(--h2-size); + + .version { + display: block; + font-size: var(--font-ui-smaller); + margin-top: var(--size-2-2); + margin-left: var(--size-4-2); + padding: var(--size-2-1) var(--size-4-1); + background-color: var(--color-base-30); + color: var(--color-base-70); + border-radius: var(--radius-s); + } + } + + .button-container { + display: flex; + gap: var(--size-4-2); + } + + h3 { + font-size: var(--font-ui-large); + margin-top: var(--heading-spacing); + } + + button, + input[type="range"], + .checkbox-container, + .slider::-webkit-slider-thumb { + cursor: pointer; + } + + textarea { + resize: none; + width: 100%; + height: 60px; + } +} + +.sync-status { + display: flex; + gap: var(--size-4-2); + + * { + display: block; + } + + .initialize-button { + padding: 0 var(--size-4-2); + background: rgba(var(--color-red-rgb), 0.4); + cursor: pointer; + } } .log-message { + font: var(--font-monospace-theme); + &.DEBUG { color: var(--color-base-50); } &.INFO { - color: var(--color-base-70); + color: var(--color-green-rgb); } &.WARNING { - color: var(--color-yellow-70); + color: var(--color-yellow-rgb); } &.ERROR { - color: var(--color-red-70); + color: var(--color-red-rgb); } - - font: var(--font-monospace-theme); -} - -.history-card * { - margin: 0; - padding: 0; } .history-card { @@ -42,6 +117,11 @@ background-color: rgba(var(--color-red-rgb), 0.2); } + * { + margin: 0; + padding: 0; + } + .history-card-header { display: flex; justify-content: space-between; diff --git a/plugin/src/views/settings-tab.ts b/plugin/src/views/settings-tab.ts index e21523a..4b6fa12 100644 --- a/plugin/src/views/settings-tab.ts +++ b/plugin/src/views/settings-tab.ts @@ -4,19 +4,44 @@ import { Notice, PluginSettingTab, Setting } from "obsidian"; import type SyncPlugin from "src/plugin"; import type { Database } from "src/database/database"; import type { SyncService } from "src/services/sync-service"; -import type { SyncHistory } from "src/tracing/sync-history"; +import { Logger } from "src/tracing/logger"; +import type { Syncer } from "src/sync-operations/syncer"; +import type { StatusDescription } from "./status-description"; +import { LogsView } from "./logs-view"; +import { HistoryView } from "./history-view"; export class SyncSettingsTab extends PluginSettingTab { private editedVaultName: string; - public constructor( - app: App, - plugin: SyncPlugin, - private readonly database: Database, - private readonly syncServer: SyncService, - private readonly history: SyncHistory - ) { + private readonly plugin: SyncPlugin; + private readonly database: Database; + private readonly syncService: SyncService; + private readonly statusDescription: StatusDescription; + private readonly syncer: Syncer; + private statusDescriptionSubscription: (() => void) | undefined; + + public constructor({ + app, + plugin, + database, + syncService, + statusDescription, + syncer, + }: { + app: App; + plugin: SyncPlugin; + database: Database; + syncService: SyncService; + statusDescription: StatusDescription; + syncer: Syncer; + }) { super(app, plugin); + this.plugin = plugin; + this.database = database; + this.syncService = syncService; + this.statusDescription = statusDescription; + this.syncer = syncer; + this.editedVaultName = this.database.getSettings().vaultName; this.database.addOnSettingsChangeHandlers( (newSettings, oldSettings) => { @@ -30,15 +55,65 @@ export class SyncSettingsTab extends PluginSettingTab { public display(): void { const { containerEl } = this; - containerEl.empty(); + containerEl.addClass("vault-link-settings"); + + containerEl.createEl("h2", { text: "VaultLink" }).createSpan({ + text: this.plugin.manifest.version, + cls: "version", + }); + + const descriptionContainer = containerEl.createDiv({ + cls: "description", + }); + this.statusDescriptionSubscription = (): void => { + this.statusDescription.renderStatusDescription( + descriptionContainer + ); + }; + this.statusDescription.addStatusChangeListener( + this.statusDescriptionSubscription + ); + + containerEl.createDiv( + { + cls: "button-container", + }, + (buttonContainer) => { + buttonContainer.createEl( + "button", + { + text: "Show history", + }, + (button) => + (button.onclick = async (): Promise => { + this.plugin.closeSettings(); + await this.plugin.activateView(HistoryView.TYPE); + }) + ); + + buttonContainer.createEl( + "button", + { + text: "Show logs", + }, + (button) => + (button.onclick = async (): Promise => { + this.plugin.closeSettings(); + await this.plugin.activateView(LogsView.TYPE); + }) + ); + } + ); + + containerEl.createEl("h3", { text: "Connection" }); new Setting(containerEl) - .setName("Remote URL") - .setDesc("Your server's URL") - .setTooltip( - "This is the URL of the server you want to sync with, todo, links to docs" + .setName("Server address") + .setDesc( + "Your VaultLink server's URL including the protocol and full path." ) + .setTooltip("This is the URL of the server you want to sync with.") .addText((text) => text .setPlaceholder("https://example.com:3030") @@ -48,44 +123,30 @@ export class SyncSettingsTab extends PluginSettingTab { ) ) .addButton((button) => - button.setButtonText("Test Connection").onClick(async () => { - try { - const result = await this.syncServer.ping(); - if (result.isAuthenticated) { - new Notice( - `Successfully authenticated with the server (version: ${result.serverVersion})!` - ); - } else { - new Notice( - `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate.` - ); - } - } catch (e) { - new Notice(`Failed to connect to server: ${e}`); - } - }) - ) - .addSlider((text) => - text - .setLimits(1, 3600, 1) - .setValue(5) - .setDynamicTooltip() - .setInstant(false) - .setValue(this.database.getSettings().uploadConcurrency) - .onChange(async (value) => - this.database.setSetting("uploadConcurrency", value) - ) - ) - .addButton((button) => - button.setButtonText("Reset sync state").onClick(async () => { - await this.database.resetSyncState(); - this.history.reset(); + button.setButtonText("Test connection").onClick(async () => { new Notice( - "Sync state has been reset, you will need to resync" + (await this.syncService.checkConnection()).message ); + await this.statusDescription.updateConnectionState(); }) ); + new Setting(containerEl) + .setName("Access token") + .setClass("sync-settings-access-token") + .setDesc( + "Set the access token for the server that you can get from the server" + ) + .setTooltip("todo, links to dcocs") + .addTextArea((text) => + text + .setPlaceholder("ey...") + .setValue(this.database.getSettings().token) + .onChange(async (value) => + this.database.setSetting("token", value) + ) + ); + new Setting(containerEl) .setName("Vault name") .setDesc( @@ -110,8 +171,25 @@ export class SyncSettingsTab extends PluginSettingTab { "vaultName", this.editedVaultName ); - await this.database.resetSyncState(); - this.history.reset(); + await this.syncer.reset(); + Logger.getInstance().reset(); + new Notice( + "Sync state has been reset, you will need to resync" + ); + }) + ); + + containerEl.createEl("h3", { text: "Sync" }); + + new Setting(containerEl) + .setName("Danger zone") + .setDesc( + "How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1." + ) + .addButton((button) => + button.setButtonText("Reset sync state").onClick(async () => { + await this.syncer.reset(); + Logger.getInstance().reset(); new Notice( "Sync state has been reset, you will need to resync" ); @@ -119,48 +197,68 @@ export class SyncSettingsTab extends PluginSettingTab { ); new Setting(containerEl) - .setName("Access token") - .setClass("sync-settings-access-token") + .setName("Remote fetching frequency (seconds)") .setDesc( - "Set the access token for the server that you can get from the server" + "Set how often should the plugin check for changes on the server. Lower values will increase the frequency of the checks making it easier to collaborate with others." ) - .setTooltip("todo, links to dcocs") - .addTextArea((text) => + .setTooltip("todo, links to docs") + .addSlider((text) => text - .setPlaceholder("ey...") - .setValue(this.database.getSettings().token) + .setLimits(0.5, 60, 0.5) + .setDynamicTooltip() + .setInstant(false) + .setValue( + this.database.getSettings() + .fetchChangesUpdateIntervalMs / 1000 + ) .onChange(async (value) => - this.database.setSetting("token", value) + this.database.setSetting( + "fetchChangesUpdateIntervalMs", + value * 1000 + ) ) ); new Setting(containerEl) - .setName("Full scan interval (seconds)") + .setName("Sync concurrency") .setDesc( - "How often would you like to do a full scan of the local files" + "How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1." + ) + .addSlider((text) => + text + .setLimits(1, 16, 1) + .setDynamicTooltip() + .setInstant(false) + .setValue(this.database.getSettings().syncConcurrency) + .onChange(async (value) => + this.database.setSetting("syncConcurrency", value) + ) + ); + + new Setting(containerEl) + .setName("Enable sync") + .setDesc( + "Enable pulling and pushing changes to the remote server. The first time it's enabled, or after the sync state has been reset, all local files will be pushed to the server." + ) + .setTooltip( + "Enable pulling and pushing changes to the remote server." ) - .setTooltip("todo, links to docs") .addToggle((toggle) => toggle .setValue(this.database.getSettings().isSyncEnabled) .onChange(async (value) => this.database.setSetting("isSyncEnabled", value) ) - ) - .addSlider((text) => - text - .setLimits(1, 3600, 1) - .setDynamicTooltip() - .setInstant(false) - .setValue( - this.database.getSettings().fetchChangesUpdateIntervalMs - ) - .onChange(async (value) => - this.database.setSetting( - "fetchChangesUpdateIntervalMs", - value - ) - ) ); } + + public hide(): void { + super.hide(); + + if (this.statusDescriptionSubscription) { + this.statusDescription.removeStatusChangeListener( + this.statusDescriptionSubscription + ); + } + } } diff --git a/plugin/src/views/status-description.ts b/plugin/src/views/status-description.ts new file mode 100644 index 0000000..1b2f536 --- /dev/null +++ b/plugin/src/views/status-description.ts @@ -0,0 +1,139 @@ +import type { Database } from "src/database/database"; +import type { + CheckConnectionResult, + SyncService, +} from "src/services/sync-service"; +import type { Syncer } from "src/sync-operations/syncer"; +import type { HistoryStats, SyncHistory } from "src/tracing/sync-history"; + +export class StatusDescription { + private lastHistoryStats: HistoryStats | undefined; + private lastRemaining: number | undefined; + private lastConnectionState: CheckConnectionResult | undefined; + + private statusChangeListeners: (() => void)[] = []; + + public constructor( + private readonly database: Database, + private readonly syncService: SyncService, + history: SyncHistory, + syncer: Syncer + ) { + void this.updateConnectionState(); + + history.addSyncHistoryUpdateListener((status) => { + this.lastHistoryStats = status; + this.updateDescription(); + }); + + syncer.addRemainingOperationsListener((remainingOperations) => { + this.lastRemaining = remainingOperations; + this.updateDescription(); + }); + + database.addOnSettingsChangeHandlers(() => { + void this.updateConnectionState(); + }); + } + + public async updateConnectionState(): Promise { + this.lastConnectionState = await this.syncService.checkConnection(); + this.updateDescription(); + } + + public addStatusChangeListener(listener: () => void): void { + this.statusChangeListeners.push(listener); + } + public removeStatusChangeListener(listener: () => void): void { + this.statusChangeListeners = this.statusChangeListeners.filter( + (l) => l !== listener + ); + } + + public renderStatusDescription(container: HTMLElement): void { + container.empty(); + container.addClass("status-description"); + + if (this.lastConnectionState == undefined) { + container.createSpan({ + text: "VaultLink is starting up…", + cls: "warning", + }); + return; + } + + if (!this.lastConnectionState.isSuccessful) { + container.createSpan({ + text: `VaultLink failed to connect to the remote server with the error "${this.lastConnectionState.message}"`, + cls: "error", + }); + return; + } + + container.createSpan({ text: "VaultLink is connected to the server " }); + container.createEl("a", { + text: this.database.getSettings().remoteUri, + href: this.database.getSettings().remoteUri, + }); + + container.createSpan({ + text: ` and has indexed approximately `, + }); + container.createSpan({ + text: `${this.database.getDocuments().size}`, + cls: "number", + }); + container.createSpan({ + text: ` documents. `, + }); + + if ( + (this.lastRemaining ?? 0) === 0 && + (this.lastHistoryStats?.success ?? 0) === 0 && + (this.lastHistoryStats?.error ?? 0) === 0 + ) { + if (this.database.getSettings().isSyncEnabled) { + container.createSpan({ + text: "Syncing is enabled but VaultLink hasn't found anything to sync yet.", + }); + } else { + container.createSpan({ + text: "However, syncing is disabled right now.", + cls: "warning", + }); + } + return; + } + + container.createSpan({ + text: "The plugin has ", + }); + container.createSpan({ + text: `${this.lastRemaining ?? 0}`, + cls: "number", + }); + container.createSpan({ + text: " outstanding operations while having succeeded ", + }); + container.createSpan({ + text: `${this.lastHistoryStats?.success ?? 0}`, + cls: ["number", "good"], + }); + container.createSpan({ + text: " times and failed ", + }); + container.createSpan({ + text: `${this.lastHistoryStats?.error ?? 0}`, + cls: ["number", "bad"], + }); + container.createSpan({ + text: " times.", + }); + } + + private updateDescription(): void { + this.statusChangeListeners.forEach((listener) => { + listener(); + }); + } +}