From 47759f62058afb6515e3816691aad7a9edccb7ff Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 24 Aug 2026 12:17:19 +0000 Subject: [PATCH] Adapt settings pages to Obsidian's declarative API --- .../2026_08_declarative_settings_adapter.md | 101 +++-- src/deps.ts | 1 + .../SettingDialogue/LiveSyncSetting.ts | 16 +- ...iveSyncSettingTab.declarative.unit.spec.ts | 328 +++++++++++++++ .../ObsidianLiveSyncSettingTab.ts | 378 +++++++++++++----- .../ObsidianLiveSyncSettingTab.unit.spec.ts | 115 +++++- .../features/SettingDialogue/PaneChangeLog.ts | 3 +- .../features/SettingDialogue/PaneGeneral.ts | 2 +- .../features/SettingDialogue/PaneHatch.ts | 8 +- .../SettingDialogue/PaneMaintenance.ts | 36 +- .../SettingDialogue/PaneRemoteConfig.ts | 11 +- .../PaneRemoteConfig.unit.spec.ts | 113 ++++++ .../features/SettingDialogue/PaneSelector.ts | 29 +- .../features/SettingDialogue/PaneSetup.ts | 24 +- .../SettingDialogue/PaneSyncSettings.ts | 2 +- .../features/SettingDialogue/SettingPane.ts | 33 +- .../SettingDialogue/SettingPane.unit.spec.ts | 44 ++ .../features/SettingDialogue/SettingSpec.ts | 12 + .../SettingDialogue/SettingsPageCatalogue.ts | 191 +++++++++ .../SettingsPageCatalogue.unit.spec.ts | 65 +++ 20 files changed, 1327 insertions(+), 185 deletions(-) create mode 100644 src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts create mode 100644 src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts create mode 100644 src/modules/features/SettingDialogue/SettingPane.unit.spec.ts create mode 100644 src/modules/features/SettingDialogue/SettingsPageCatalogue.ts create mode 100644 src/modules/features/SettingDialogue/SettingsPageCatalogue.unit.spec.ts diff --git a/docs/adr/2026_08_declarative_settings_adapter.md b/docs/adr/2026_08_declarative_settings_adapter.md index 2d84e82a..84279642 100644 --- a/docs/adr/2026_08_declarative_settings_adapter.md +++ b/docs/adr/2026_08_declarative_settings_adapter.md @@ -104,11 +104,20 @@ of two native content forms: type SettingsPageEntry = { id: string; name: () => string; + icon: string; + order: number; + level?: ConfigLevel; + content: "native" | "custom"; legacy: PaneRenderer; - native: { items: () => SettingDefinitionItem[] } | { page: () => SettingPage }; }; ``` +In Stage C1, `native` identifies the Advanced proof page, whose definitions are +supplied by the adapter, and `custom` selects the shared lazy custom-page +factory. The catalogue will gain a per-page native factory only when a second +native page requires one; Stage C1 does not introduce that abstraction in +advance. + A native `items` page may mix groups of `SettingSpec` controls with Obsidian's direct action, render, list, and nested-page definitions. A native custom `SettingPage` is the final escape hatch when the page cannot yet be divided @@ -124,15 +133,21 @@ This makes page names and visibility consistent without requiring every page to migrate at once. Page names must be unique because Obsidian uses them for nested navigation. -The custom `SettingPage` adapter will be created lazily from the 1.13-or-later -path. It must feature-detect the runtime API and must not instantiate or -subclass `SettingPage` while the module is loading on an older supported -Obsidian version. The adapter sets `title` from the catalogue and renders pane -content into the host-provided `containerEl`. Its `hide()` boundary will unload -the page-owned `Component`, unmount Svelte and markdown content, and remove -page-owned update handlers. The parent tab's `hide()` remains a final cleanup -boundary because Obsidian does not guarantee a page-level `hide()` call when -the host window is destroyed. +The custom `SettingPage` adapter class will be constructed lazily from the +1.13-or-later path. `SettingPage` may remain a normal runtime import because the +bundle reads Obsidian exports through its namespace object, but the import must +not be subclassed or instantiated while the module is loading. The factory +will first use `requireApiVersion("1.13.0")`, then verify that `SettingPage` is +available. Older supported Obsidian versions therefore continue to call the +imperative `display()` fallback without requiring a dynamic import or a +polyfill for host behaviour which does not exist in those versions. + +The adapter sets `title` from the catalogue and renders pane content into the +host-provided `containerEl`. Its `hide()` boundary will unload the page-owned +`Component`, unmount Svelte and markdown content, and remove page-owned update +handlers. The parent tab's `hide()` remains a final cleanup boundary because +Obsidian does not guarantee a page-level `hide()` call when the host window is +destroyed. Custom pages receive only the current page's `containerEl` and the existing `addPanel` helper. They do not recreate the old top-level tab menu inside each @@ -299,6 +314,10 @@ Each imperative render will therefore receive a small page scope containing: The legacy `display()` fallback uses one scope for the complete old tab. A custom declarative page creates one scope when opened and disposes it when hidden. This scope is renderer state and is not part of `SettingSpec`. +Pane-construction callbacks which are queued by the existing helpers run only +whilst the scope which requested them remains current. Closing or replacing a +page therefore cannot attach delayed controls or cleanup callbacks to its +successor. Saved-setting effects remain owned by the tab session, not by a DOM page. The existing handlers are unique by setting key, so `addOnSaved()` will replace the @@ -354,10 +373,11 @@ language re-renders the interface and other controls emit status events after saving. Those effects should remain imperative until the standard binding has been proven. -At Stage C, other pages use native groups and searchable rows where their -existing panels divide cleanly. Only the remaining full custom pages are limited -to page-level search. A later, focused migration can split those workflows into -standard, action, or rendered rows without changing the page catalogue. +The first native activation does not also divide other pages into searchable +rows. It exposes their established pane renderers as custom pages, limited to +page-level search. A later, optional migration can replace an individual custom +page with standard, action, or rendered rows without changing the page +catalogue. ## Implementation Stages and Checkpoint @@ -382,22 +402,43 @@ shared model can express a real page without first taking ownership of every page's lifetime. Returning an empty definition array merely to silence review output is not an outcome of this stage. -### Stage C: activate native pages +### Stage C1: activate the native catalogue Activation is a separate checkpoint because it is the first cross-cutting change. It will add the page catalogue, custom `SettingPage` adapter, scoped imperative lifetime, renderer-neutral refresh operation, declarative control read and write overrides, and non-empty definitions on Obsidian 1.13 or later. -It will expose each remaining pane through native groups and rendered rows where -the existing panels divide cleanly, use a full custom native page only as a -fallback, and retain the imperative renderer for older Obsidian versions. + +The non-empty definition array replaces `display()` completely. Partial +activation is therefore not safe: all 12 existing pages must enter the native +catalogue together. Advanced is the only page represented by native groups in +this stage. The other 11 pages use their existing pane renderers inside lazy +custom pages. Obsidian versions before 1.13 retain the complete imperative +renderer and its menu. + +The existing rebuild-required action remains available while navigating native +pages. Custom pages render the established action at their page boundary, and +the Advanced definition includes an equivalent action item whose visibility is +derived from the same dirty-state predicate. Both forms call the existing +`confirmRebuild()` owner rather than introducing another apply workflow. This stage necessarily touches direct `display()` callers, saved-handler -ownership, and cleanup for Svelte and markdown content. Review its measured -patch and focused test plan with the maintainer before implementation. Do not -expand `SettingSpec` to absorb those concerns merely to make activation appear +ownership, and cleanup for Svelte and markdown content. It does not expand +`SettingSpec` to absorb those concerns merely to make activation appear smaller. +### Stage C2: improve search coverage selectively + +After activation, an individual custom page may be replaced with native groups, +actions, and rendered rows where the existing panel boundary maps cleanly to +Obsidian's definitions. This is optional follow-up work rather than a condition +of Stage C1. Complex workflows may remain custom pages indefinitely. + +Stage C2 must not introduce a general action or lifecycle language. Each page +conversion should be justified by useful settings-search coverage and retain +the catalogue, persistence owner, and refresh boundaries established by Stage +C1. + ## Verification Stage A will run the maintained onboarding E2E scenario and an ordinary @@ -413,14 +454,19 @@ Stage B focused unit tests will verify: - rendering the Advanced specifications through `LiveSyncSetting` preserves the current save behaviour. -Stage C focused unit tests will verify: +Stage C1 focused unit tests will verify: -- the page catalogue has stable, unique identifiers and names; +- the page catalogue contains all 12 existing pages with stable, unique + identifiers and names; +- Advanced is the only native-items page, while the other 11 pages retain + custom factories; - every standard setting key is registered once; - reads use the editing buffer; -- writes use `saveSettings([key])` and never `plugin.settings`; and -- custom pages remain custom rather than being flattened into incomplete - definitions. +- writes use `saveSettings([key])` and never `plugin.settings`; +- custom pages dispose their page-owned resources and do not duplicate saved + handlers when reopened; and +- importing and opening the imperative fallback does not evaluate or require + `SettingPage` on Obsidian before 1.13. Real-Obsidian verification on 1.13 or later will confirm: @@ -429,7 +475,8 @@ Real-Obsidian verification on 1.13 or later will confirm: - Advanced values persist and are restored after reopening settings; - CouchDB-dependent controls and Advanced-mode visibility update correctly; - a representative custom page, including its cleanup, still works; -- page and catalogue refreshes preserve native navigation; and +- page and catalogue refreshes preserve native navigation and the + rebuild-required action; and - no duplicate save, update handler, or saved-setting effect occurs after leaving and reopening a page. diff --git a/src/deps.ts b/src/deps.ts index 0e4eb819..bac2545c 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -16,6 +16,7 @@ export { requestUrl, sanitizeHTMLToDom, Setting, + SettingPage, stringifyYaml, TAbstractFile, TextAreaComponent, diff --git a/src/modules/features/SettingDialogue/LiveSyncSetting.ts b/src/modules/features/SettingDialogue/LiveSyncSetting.ts index b7876678..adaf66cb 100644 --- a/src/modules/features/SettingDialogue/LiveSyncSetting.ts +++ b/src/modules/features/SettingDialogue/LiveSyncSetting.ts @@ -8,7 +8,12 @@ import { type ValueComponent, } from "@/deps.ts"; import { unique } from "octagonal-wheels/collection"; -import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + LEVEL_ADVANCED, + LEVEL_POWER_USER, + statusDisplay, + type ConfigurationItem, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import { type AllSettingItemKey, @@ -19,7 +24,7 @@ import { type AllBooleanItemKey, } from "./settingConstants.ts"; import { $msg } from "@/common/translation"; -import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts"; +import { setButtonDestructiveState, wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts"; export class LiveSyncSetting extends Setting { autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent; @@ -307,12 +312,7 @@ export class LiveSyncSetting extends Setting { { const component = this.autoWiredComponent; if (component instanceof ButtonComponent) { - if (newConf[k]) { - component.setWarning(); - } else { - //TODO:IMPLEMENT - // component.removeCta(); - } + setButtonDestructiveState(component, newConf[k] ?? false); } this.prevStatus[k] = newConf[k]; } diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts new file mode 100644 index 00000000..c766fe81 --- /dev/null +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { SettingDefinitionItem, SettingDefinitionPage } from "obsidian"; +import type { PageFunctions } from "./SettingPane.ts"; + +const runtime = vi.hoisted(() => ({ + components: [] as Array<{ + load: ReturnType; + unload: ReturnType; + callbacks: Array<() => unknown>; + }>, + paneGeneral: vi.fn(), + pageCleanup: vi.fn(), + savedEffect: vi.fn(), + superHide: vi.fn(), +})); + +function createElement(): HTMLElement { + const element = { + empty: vi.fn(), + addClass: vi.fn(), + removeClass: vi.fn(), + toggleClass: vi.fn(), + createEl: vi.fn(() => createElement()), + createDiv: vi.fn(() => createElement()), + querySelectorAll: vi.fn(() => []), + }; + return element as unknown as HTMLElement; +} + +vi.mock("@/deps.ts", () => ({ + App: class {}, + Component: class { + callbacks: Array<() => unknown> = []; + load = vi.fn(); + unload = vi.fn(() => { + for (const callback of this.callbacks.splice(0)) callback(); + }); + register = vi.fn((callback: () => unknown) => this.callbacks.push(callback)); + constructor() { + runtime.components.push(this); + } + }, + PluginSettingTab: class { + app: unknown; + plugin: unknown; + refreshDomState = vi.fn(); + update = vi.fn(); + constructor(app: unknown, plugin: unknown) { + this.app = app; + this.plugin = plugin; + } + hide() {} + }, + SettingPage: class { + containerEl = createElement(); + title = ""; + display() {} + hide() { + runtime.superHide(); + } + }, + requireApiVersion: vi.fn(() => true), +})); +vi.mock("@/main.ts", () => ({ default: class {} })); +vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({ + getLanguage: vi.fn(() => "en"), + compatGlobal: { + localStorage: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }, + }, +})); +vi.mock("@/common/events.ts", () => ({ + EVENT_REQUEST_RELOAD_SETTING_TAB: "request-reload-setting-tab", + eventHub: { onEvent: vi.fn() }, +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({ checkSyncInfo: vi.fn() })); +vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ + LiveSyncCouchDBReplicator: class {}, +})); +vi.mock("./LiveSyncSetting.ts", () => ({ + LiveSyncSetting: class { + static env: unknown; + }, +})); +vi.mock("./SettingPane.ts", () => ({ + enableOnly: vi.fn((condition: () => boolean) => () => ({ disabled: !condition() })), + setLevelClass: vi.fn(), + setStyle: vi.fn(), + visibleOnly: vi.fn((condition: () => boolean) => () => ({ visibility: condition() })), +})); +vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() })); +vi.mock("./PaneSetup.ts", () => ({ paneSetup: vi.fn() })); +vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: runtime.paneGeneral })); +vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() })); +vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() })); +vi.mock("./PaneSyncSettings.ts", () => ({ paneSyncSettings: vi.fn() })); +vi.mock("./PaneCustomisationSync.ts", () => ({ paneCustomisationSync: vi.fn() })); +vi.mock("./PaneHatch.ts", () => ({ paneHatch: vi.fn() })); +vi.mock("./PaneAdvanced.ts", () => ({ paneAdvanced: vi.fn() })); +vi.mock("./PanePowerUsers.ts", () => ({ panePowerUsers: vi.fn() })); +vi.mock("./PanePatches.ts", () => ({ panePatches: vi.fn() })); +vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() })); + +import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; + +function isPage(item: SettingDefinitionItem): item is SettingDefinitionPage { + return "type" in item && item.type === "page"; +} + +function createSettingsTab(): ObsidianLiveSyncSettingTab { + const plugin = { + app: {}, + core: { + settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, + confirm: { + askInPopup: vi.fn(), + }, + services: { + setting: { + getDeviceAndVaultName: vi.fn(() => ""), + saveSettingData: vi.fn(async () => undefined), + }, + }, + }, + }; + const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never); + Object.assign(tab, { + _editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, + initialSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, + }); + return tab; +} + +beforeEach(() => { + runtime.components.length = 0; + runtime.paneGeneral.mockClear(); + runtime.paneGeneral.mockImplementation(function (this: ObsidianLiveSyncSettingTab) { + this.lifetimeComponent.register(runtime.pageCleanup); + }); + runtime.pageCleanup.mockClear(); + runtime.savedEffect.mockClear(); + runtime.superHide.mockClear(); +}); + +describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { + it("returns all catalogue pages and keeps Advanced as native items", () => { + const tab = createSettingsTab(); + const pages = tab.getSettingDefinitions().filter(isPage); + + expect(pages).toHaveLength(12); + const advanced = pages.find(({ name }) => name === "Advanced"); + expect(advanced?.items?.filter((item) => "type" in item && item.type === "group")).toHaveLength(4); + expect(advanced?.items?.filter((item) => "action" in item && typeof item.action === "function")).toHaveLength( + 1 + ); + expect(advanced?.page).toBeUndefined(); + expect(pages.filter(({ page }) => page !== undefined)).toHaveLength(11); + }); + + it("constructs custom page state only when opened and disposes each rendered scope", () => { + const tab = createSettingsTab(); + const general = tab.getSettingDefinitions().filter(isPage)[2]; + if (!general?.page) { + throw new Error("General custom page is unavailable"); + } + + expect(runtime.components).toHaveLength(0); + const page = general.page(); + expect(runtime.components).toHaveLength(0); + + page.display(); + expect(runtime.paneGeneral).toHaveBeenCalledOnce(); + expect(runtime.components).toHaveLength(1); + expect(runtime.components[0].load).toHaveBeenCalledOnce(); + + page.display(); + expect(runtime.components[0].unload).toHaveBeenCalledOnce(); + expect(runtime.pageCleanup).toHaveBeenCalledOnce(); + expect(runtime.components).toHaveLength(2); + + page.hide(); + expect(runtime.components[1].unload).toHaveBeenCalledOnce(); + expect(runtime.pageCleanup).toHaveBeenCalledTimes(2); + expect(runtime.superHide).toHaveBeenCalledOnce(); + }); + + it("does not run delayed pane work after its page scope has been disposed", async () => { + runtime.paneGeneral.mockImplementation(function ( + this: ObsidianLiveSyncSettingTab, + _paneEl: HTMLElement, + { addPanel }: Pick + ) { + void addPanel(createElement(), "Delayed panel").then(() => { + this.lifetimeComponent.register(runtime.pageCleanup); + }); + }); + const tab = createSettingsTab(); + const general = tab.getSettingDefinitions().filter(isPage)[2]; + if (!general?.page) { + throw new Error("General custom page is unavailable"); + } + + const page = general.page(); + page.display(); + page.hide(); + await Promise.resolve(); + + expect(runtime.pageCleanup).not.toHaveBeenCalled(); + }); + + it("runs a delayed pane callback inside its active scope before a queued hide", async () => { + runtime.paneGeneral.mockImplementation(function ( + this: ObsidianLiveSyncSettingTab, + _paneEl: HTMLElement, + { addPanel }: Pick + ) { + void addPanel(createElement(), "Delayed panel").then(() => { + this.lifetimeComponent.register(runtime.pageCleanup); + }); + }); + const tab = createSettingsTab(); + const general = tab.getSettingDefinitions().filter(isPage)[2]; + if (!general?.page) { + throw new Error("General custom page is unavailable"); + } + + const page = general.page(); + page.display(); + queueMicrotask(() => page.hide()); + await Promise.resolve(); + await Promise.resolve(); + + expect(runtime.pageCleanup).toHaveBeenCalledOnce(); + }); + + it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => { + const tab = createSettingsTab(); + const general = tab.getSettingDefinitions().filter(isPage)[2]; + if (!general?.page) { + throw new Error("General custom page is unavailable"); + } + general.page().display(); + tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode; + + tab.requestReload(); + + expect(tab.update).toHaveBeenCalledOnce(); + }); + + it("rebuilds translated catalogue names when the display language changes externally", () => { + const tab = createSettingsTab(); + const general = tab.getSettingDefinitions().filter(isPage)[2]; + if (!general?.page) { + throw new Error("General custom page is unavailable"); + } + general.page().display(); + tab.core.settings.displayLanguage = "ja"; + + tab.requestReload(); + + expect(tab.update).toHaveBeenCalledOnce(); + }); + + it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => { + const tab = createSettingsTab(); + const general = tab.getSettingDefinitions().filter(isPage)[2]; + if (!general?.page) { + throw new Error("General custom page is unavailable"); + } + general.page().display(); + tab.initialSettings!.usePowerUserMode = false; + tab.editingSettings.usePowerUserMode = true; + tab.core.settings.usePowerUserMode = true; + + tab.requestReload(); + const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2]; + expect(configureAnchor).toBeTypeOf("function"); + let acceptExternalSetting: (() => void) | undefined; + configureAnchor?.({ + text: "", + addEventListener: vi.fn((_event: string, callback: () => void) => { + acceptExternalSetting = callback; + }), + } as unknown as HTMLAnchorElement); + expect(acceptExternalSetting).toBeTypeOf("function"); + vi.mocked(tab.update).mockClear(); + acceptExternalSetting!(); + + expect(tab.update).toHaveBeenCalledOnce(); + }); + + it("does not reopen a custom page when a catalogue update has already hidden it", () => { + const tab = createSettingsTab(); + const general = tab.getSettingDefinitions().filter(isPage)[2]; + if (!general?.page) { + throw new Error("General custom page is unavailable"); + } + const page = general.page(); + page.display(); + vi.mocked(tab.update).mockImplementation(() => page.hide()); + + tab.requestCatalogueRefresh(); + + expect(runtime.paneGeneral).toHaveBeenCalledOnce(); + }); + + it("keeps saved-setting effects owned by the tab after a custom page closes", async () => { + runtime.paneGeneral.mockImplementation(function (this: ObsidianLiveSyncSettingTab) { + this.addOnSaved("displayLanguage", runtime.savedEffect); + }); + const tab = createSettingsTab(); + const general = tab.getSettingDefinitions().filter(isPage)[2]; + if (!general?.page) { + throw new Error("General custom page is unavailable"); + } + + const page = general.page(); + page.display(); + page.hide(); + tab.editingSettings.displayLanguage = "ja"; + await tab.saveSettings(["displayLanguage"]); + + expect(runtime.savedEffect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index 3abeb4d9..86ec7a6e 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -1,4 +1,4 @@ -import { App, Component, PluginSettingTab } from "@/deps.ts"; +import { App, Component, PluginSettingTab, requireApiVersion, SettingPage } from "@/deps.ts"; import { type ObsidianLiveSyncSettings, type RemoteDBSettings, @@ -34,7 +34,6 @@ import { $msg } from "@/common/translation"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import { fireAndForget, yieldNextAnimationFrame } from "octagonal-wheels/promises"; import { EVENT_REQUEST_RELOAD_SETTING_TAB, eventHub } from "@/common/events.ts"; -import { paneChangeLog } from "./PaneChangeLog.ts"; import { enableOnly, // findAttrFromParent, @@ -46,32 +45,34 @@ import { type OnSavedHandlerFunc, type OnUpdateFunc, type OnUpdateResult, + type DeferredPageElement, type PageFunctions, type UpdateFunction, } from "./SettingPane.ts"; -import { paneSetup } from "./PaneSetup.ts"; -import { paneGeneral } from "./PaneGeneral.ts"; -import { paneRemoteConfig } from "./PaneRemoteConfig.ts"; -import { paneSelector } from "./PaneSelector.ts"; -import { paneSyncSettings } from "./PaneSyncSettings.ts"; -import { paneCustomisationSync } from "./PaneCustomisationSync.ts"; -import { paneHatch } from "./PaneHatch.ts"; -import { paneAdvanced } from "./PaneAdvanced.ts"; -import { panePowerUsers } from "./PanePowerUsers.ts"; -import { panePatches } from "./PanePatches.ts"; -import { paneMaintenance } from "./PaneMaintenance.ts"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore"; import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter"; import { closeObsidianSettings } from "@/common/obsidianSettings.ts"; +import { + createAdvancedSettingDefinitionGroups, + createSettingsPageCatalogue, + type SettingsPageEntry, +} from "./SettingsPageCatalogue.ts"; +import { createAdvancedSettingSpecGroups } from "./AdvancedSettingSpecs.ts"; +import { isValidSettingSpecValue, type SettingSpec } from "./SettingSpec.ts"; +import type { SettingDefinitionAction, SettingDefinitionItem, SettingDefinitionPage } from "obsidian"; // For creating a document // const toc = new Set(); export class ObsidianLiveSyncSettingTab extends PluginSettingTab { plugin: ObsidianLiveSyncPlugin; - private _lifetimeComponent: Component = new Component(); + private _lifetimeComponent?: Component; + private activePageRefresh?: () => void; get lifetimeComponent(): Component { + if (!this._lifetimeComponent) { + throw new Error("The settings page render scope has not been initialised"); + } return this._lifetimeComponent; } get core() { @@ -200,9 +201,39 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { for (const func of this.controlledElementFunc) { func(); } + if (requireApiVersion("1.13.0") && typeof this.refreshDomState === "function") { + this.refreshDomState(); + } }); } + /** Re-render the active imperative page without assuming which settings renderer owns it. */ + requestPageRefresh() { + if (this.activePageRefresh) { + this.activePageRefresh(); + return; + } + if (requireApiVersion("1.13.0") && typeof SettingPage === "function" && typeof this.update === "function") { + this.update(); + return; + } + this.displayImperative(); + } + + /** Rebuild the native page catalogue and preserve the current imperative page where possible. */ + requestCatalogueRefresh() { + if (requireApiVersion("1.13.0") && typeof SettingPage === "function" && typeof this.update === "function") { + const refreshPage = this.activePageRefresh; + const owner = this._lifetimeComponent; + this.update(); + if (owner && this._lifetimeComponent === owner) { + refreshPage?.(); + } + } else { + this.displayImperative(); + } + } + reloadAllLocalSettings() { const ret = { ...OnDialogSettingsDefault }; ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || ""; @@ -355,7 +386,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { addOnSaved(key: T, func: OnSavedHandlerFunc) { const newHandler = { key, handler: func } as OnSavedHandler; - this.onSavedHandlers.push(newHandler); + const existing = this.onSavedHandlers.findIndex((handler) => handler.key === key); + if (existing === -1) { + this.onSavedHandlers.push(newHandler); + } else { + this.onSavedHandlers.splice(existing, 1, newHandler); + } } resetEditingSettings() { this._editingSettings = undefined; @@ -363,17 +399,29 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { } override hide() { + this.disposeRenderScope(); super.hide(); - this._lifetimeComponent.unload(); this.isShown = false; } isShown: boolean = false; + private changesPageCatalogue(key: AllSettingItemKey): boolean { + return ( + key === "displayLanguage" || + key === "useAdvancedMode" || + key === "usePowerUserMode" || + key === "useEdgeCaseMode" + ); + } + requestReload() { - if (this.isShown) { + const nativeTabIsShown = + this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown(); + if (this.isShown || nativeTabIsShown) { const newConf = this.core.settings; const keys = Object.keys(newConf) as (keyof ObsidianLiveSyncSettings)[]; let hasLoaded = false; + let catalogueVisibilityChanged = false; for (const k of keys) { if (isObjectDifferent(newConf[k], this.initialSettings?.[k])) { // Something has changed @@ -388,7 +436,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { anchor.text = $msg("obsidianLiveSyncSettingTab.optionHere"); anchor.addEventListener("click", () => { this.refreshSetting(k as AllSettingItemKey); - this.display(); + if (this.changesPageCatalogue(k as AllSettingItemKey)) { + this.requestCatalogueRefresh(); + } else { + this.requestPageRefresh(); + } }); } ); @@ -399,11 +451,18 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { continue; } hasLoaded = true; + if (this.changesPageCatalogue(k as AllSettingItemKey)) { + catalogueVisibilityChanged = true; + } } } } if (hasLoaded) { - this.display(); + if (catalogueVisibilityChanged) { + this.requestCatalogueRefresh(); + } else { + this.requestPageRefresh(); + } } else { this.requestUpdate(); } @@ -488,6 +547,203 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { return false; } + private supportsDeclarativeSettings(): boolean { + return requireApiVersion("1.13.0") && typeof SettingPage === "function"; + } + + private isPageVisible(level?: ConfigLevel): boolean { + if (level === LEVEL_ADVANCED) { + return this.isConfiguredAs("useAdvancedMode", true); + } + if (level === LEVEL_POWER_USER) { + return this.isConfiguredAs("usePowerUserMode", true); + } + if (level === LEVEL_EDGE_CASE) { + return this.isConfiguredAs("useEdgeCaseMode", true); + } + return true; + } + + private getDeclarativeSettingSpec(key: string): SettingSpec { + const spec = createAdvancedSettingSpecGroups({ + isCouchDB: () => this.isConfiguredAs("remoteType", REMOTE_COUCHDB), + }) + .flatMap((group) => group.items) + .find((candidate) => candidate.key === key); + if (!spec) { + throw new Error(`Unknown declarative setting key: ${key}`); + } + return spec; + } + + override getControlValue(key: string): unknown { + const spec = this.getDeclarativeSettingSpec(key); + return this.editingSettings[spec.key]; + } + + override async setControlValue(key: string, value: unknown): Promise { + const spec = this.getDeclarativeSettingSpec(key); + if (!isValidSettingSpecValue(spec, value)) { + throw new TypeError(`Invalid value for declarative setting ${key}`); + } + Reflect.set(this.editingSettings, spec.key, value); + await this.saveSettings([spec.key]); + } + + private createRebuildRequiredAction(): SettingDefinitionAction { + return { + name: $msg("obsidianLiveSyncSettingTab.optionApply"), + desc: $msg("obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied"), + visible: () => this.isNeedRebuildLocal() || this.isNeedRebuildRemote(), + action: () => fireAndForget(async () => await this.confirmRebuild()), + }; + } + + private renderRebuildRequiredAction(parentEl: HTMLElement): void { + this.createEl( + parentEl, + "div", + { cls: "sls-setting-menu-buttons" }, + (el) => { + el.createEl("label", { text: $msg("obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied") }); + void this.addEl( + el, + "button", + { text: $msg("obsidianLiveSyncSettingTab.optionApply"), cls: "mod-warning" }, + (buttonEl) => { + buttonEl.addEventListener("click", () => + fireAndForget(async () => await this.confirmRebuild()) + ); + } + ); + }, + visibleOnly(() => this.isNeedRebuildLocal() || this.isNeedRebuildRemote()) + ); + } + + private addPanel( + parentEl: HTMLElement, + title: string, + callback?: (el: HTMLDivElement) => void, + func?: OnUpdateFunc, + level?: ConfigLevel + ): DeferredPageElement { + const owner = this.lifetimeComponent; + const el = this.createEl(parentEl, "div", { text: "" }, callback, func); + setLevelClass(el, level); + this.createEl(el, "h4", { text: title, cls: "sls-setting-panel-title" }); + return this.resolveWithinRenderScope(el, owner); + } + + /** Run delayed pane construction only while the requesting page still owns the render scope. */ + private resolveWithinRenderScope(value: T, owner: Component): DeferredPageElement { + return { + then: (callback) => { + queueMicrotask(() => { + if (this._lifetimeComponent === owner) { + callback(value); + } + }); + }, + }; + } + + private renderCustomPage(page: SettingPage, entry: SettingsPageEntry): Component { + if (requireApiVersion("1.13.0")) { + const component = this.beginRenderScope(() => page.display()); + this.isShown = true; + page.title = entry.name(); + page.containerEl.empty(); + page.containerEl.addClass("sls-setting"); + setStyle(page.containerEl, "menu-setting-poweruser", () => this.isConfiguredAs("usePowerUserMode", true)); + setStyle(page.containerEl, "menu-setting-advanced", () => this.isConfiguredAs("useAdvancedMode", true)); + setStyle(page.containerEl, "menu-setting-edgecase", () => this.isConfiguredAs("useEdgeCaseMode", true)); + this.renderRebuildRequiredAction(page.containerEl); + + const addPane: PageFunctions["addPane"] = (parentEl, title, _icon, _order, level) => { + const paneEl = this.createEl(parentEl, "div", { text: "" }); + setLevelClass(paneEl, level); + new Setting(paneEl).setName(title).setHeading().setClass("sls-setting-pane-title"); + return this.resolveWithinRenderScope(paneEl, component); + }; + entry.legacy.call(this, page.containerEl, { + addPane, + addPanel: this.addPanel.bind(this), + }); + this.requestUpdate(); + return component; + } + throw new Error("Custom settings pages require Obsidian 1.13.0 or later"); + } + + private createCustomSettingPage(entry: SettingsPageEntry): SettingPage { + if (requireApiVersion("1.13.0") && typeof SettingPage === "function") { + const renderCustomPage = this.renderCustomPage.bind(this); + const disposeRenderScope = this.disposeRenderScope.bind(this); + return new (class extends SettingPage { + private scope?: Component; + override title = entry.name(); + + override display(): void { + this.scope = renderCustomPage(this, entry); + } + + override hide(): void { + disposeRenderScope(this.scope); + this.scope = undefined; + super.hide(); + } + })(); + } + throw new Error("Custom settings pages require Obsidian 1.13.0 or later"); + } + + override getSettingDefinitions(): SettingDefinitionItem[] { + if (!this.supportsDeclarativeSettings()) { + return []; + } + return createSettingsPageCatalogue().map((entry): SettingDefinitionPage => { + const page: SettingDefinitionPage = { + type: "page", + name: entry.name(), + visible: () => this.isPageVisible(entry.level), + }; + if (entry.content === "native") { + page.items = [ + this.createRebuildRequiredAction(), + ...createAdvancedSettingDefinitionGroups({ + isCouchDB: () => this.isConfiguredAs("remoteType", REMOTE_COUCHDB), + }), + ]; + } else { + page.page = () => this.createCustomSettingPage(entry); + } + return page; + }); + } + + private beginRenderScope(refresh: () => void): Component { + this.disposeRenderScope(); + const component = new Component(); + this._lifetimeComponent = component; + this.activePageRefresh = refresh; + this.settingComponents.length = 0; + this.controlledElementFunc.length = 0; + component.load(); + return component; + } + + private disposeRenderScope(owner?: Component): void { + if (owner && this._lifetimeComponent !== owner) { + return; + } + this._lifetimeComponent?.unload(); + this._lifetimeComponent = undefined; + this.activePageRefresh = undefined; + this.settingComponents.length = 0; + this.controlledElementFunc.length = 0; + } + enableOnlySyncDisabled = enableOnly(() => !this.isAnySyncEnabled()); onlyOnP2POrCouchDB = () => @@ -631,14 +887,16 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { } } + // The imperative renderer remains required by the declared Obsidian 1.7.2 minimum version. override display(): void { + this.displayImperative(); + } + + private displayImperative(): void { const changeDisplay = this.changeDisplay.bind(this); - // Make sure lifetime component is loaded for markdown rendering in panes. - this._lifetimeComponent.load(); + // Make sure the page-owned component is loaded for markdown rendering in panes. + this.beginRenderScope(() => this.displayImperative()); const { containerEl } = this; - this.settingComponents.length = 0; - this.controlledElementFunc.length = 0; - this.onSavedHandlers.length = 0; this.screenElements = {}; if (this._editingSettings == undefined || this.initialSettings == undefined) { this.reloadAllSettings(); @@ -666,28 +924,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { this.menuEl.addClass("sls-setting-menu"); const menuTabs = this.menuEl.querySelectorAll(".sls-setting-label"); - this.createEl( - menuWrapper, - "div", - { cls: "sls-setting-menu-buttons" }, - (el) => { - el.createEl("label", { text: $msg("obsidianLiveSyncSettingTab.msgChangesNeedToBeApplied") }); - void this.addEl( - el, - "button", - { text: $msg("obsidianLiveSyncSettingTab.optionApply"), cls: "mod-warning" }, - (buttonEl) => { - buttonEl.addEventListener("click", () => - fireAndForget(async () => await this.confirmRebuild()) - ); - } - ); - }, - visibleOnly(() => this.isNeedRebuildLocal() || this.isNeedRebuildRemote()) - ); + this.renderRebuildRequiredAction(menuWrapper); // let paneNo = 0; const addPane = (parentEl: HTMLElement, title: string, icon: string, order: number, level?: ConfigLevel) => { + const owner = this.lifetimeComponent; const el = this.createEl(parentEl, "div", { text: "" }); setLevelClass(el, level); @@ -711,30 +952,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { }); } this.addScreenElement(`${order}`, el); - const p = Promise.resolve(el); - // fireAndForget - // p.finally(() => { - // // Recap at the end. - // }); - return p; + return this.resolveWithinRenderScope(el, owner); }; // const panelNoMap = {} as { [key: string]: number }; - const addPanel = ( - parentEl: HTMLElement, - title: string, - callback?: (el: HTMLDivElement) => void, - func?: OnUpdateFunc, - level?: ConfigLevel - ) => { - const el = this.createEl(parentEl, "div", { text: "" }, callback, func); - setLevelClass(el, level); - this.createEl(el, "h4", { text: title, cls: "sls-setting-panel-title" }); - const p = Promise.resolve(el); - // p.finally(() => { - // // Recap at the end. - // }) - return p; - }; + const addPanel = this.addPanel.bind(this); menuTabs.forEach((element) => { const e = element.querySelector(".sls-setting-tab"); @@ -762,30 +983,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { // Add panes - // TODO: Refactor to new API style. - void addPane(containerEl, $msg("obsidianLiveSyncSettingTab.panelChangeLog"), "💬", 100).then( - bindPane(paneChangeLog) - ); - void addPane(containerEl, $msg("obsidianLiveSyncSettingTab.panelSetup"), "🧙‍♂️", 110).then(bindPane(paneSetup)); - void addPane(containerEl, $msg("obsidianLiveSyncSettingTab.panelGeneralSettings"), "⚙️", 20).then( - bindPane(paneGeneral) - ); - void addPane(containerEl, $msg("obsidianLiveSyncSettingTab.panelRemoteConfiguration"), "🛰️", 0).then( - bindPane(paneRemoteConfig) - ); - void addPane(containerEl, $msg("obsidianLiveSyncSettingTab.titleSyncSettings"), "🔄", 30).then( - bindPane(paneSyncSettings) - ); - void addPane(containerEl, "Selector", "🚦", 33, LEVEL_ADVANCED).then(bindPane(paneSelector)); - void addPane(containerEl, "Customization sync", "🔌", 60, LEVEL_ADVANCED).then(bindPane(paneCustomisationSync)); - - void addPane(containerEl, "Hatch", "🧰", 50).then(bindPane(paneHatch)); - void addPane(containerEl, "Advanced", "🔧", 46, LEVEL_ADVANCED).then(bindPane(paneAdvanced)); - void addPane(containerEl, "Power users", "💪", 47, LEVEL_POWER_USER).then(bindPane(panePowerUsers)); - - void addPane(containerEl, "Patches", "🩹", 51, LEVEL_EDGE_CASE).then(bindPane(panePatches)); - - void addPane(containerEl, "Maintenance", "🎛️", 70).then(bindPane(paneMaintenance)); + for (const entry of createSettingsPageCatalogue()) { + void addPane(containerEl, entry.name(), entry.icon, entry.order, entry.level).then(bindPane(entry.legacy)); + } void yieldNextAnimationFrame().then(() => { if (this.selectedScreen == "") { diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts index 9b79a865..6f99658a 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts @@ -7,10 +7,25 @@ const negotiationMocks = vi.hoisted(() => ({ vi.mock("@/deps.ts", () => ({ App: class {}, - Component: class {}, + Component: class { + load = vi.fn(); + unload = vi.fn(); + register = vi.fn(); + }, PluginSettingTab: class {}, + SettingPage: undefined, + requireApiVersion: vi.fn(() => false), })); vi.mock("@/main.ts", () => ({ default: class {} })); +vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({ + getLanguage: vi.fn(() => "en"), + compatGlobal: { + localStorage: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }, + }, +})); vi.mock("@/common/events.ts", () => ({ EVENT_REQUEST_RELOAD_SETTING_TAB: "request-reload-setting-tab", eventHub: { onEvent: vi.fn() }, @@ -73,3 +88,101 @@ describe("ObsidianLiveSyncSettingTab passphrase verification", () => { expect(remoteDatabase.close).toHaveBeenCalledOnce(); }); }); + +describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => { + function createSettingsTab() { + const saveSettingData = vi.fn(async () => undefined); + const plugin = { + app: {}, + core: { + settings: { + ...DEFAULT_SETTINGS, + hashCacheMaxCount: 300, + displayLanguage: "", + }, + services: { + setting: { + saveSettingData, + getDeviceAndVaultName: vi.fn(() => ""), + }, + }, + }, + }; + Object.defineProperty(plugin, "settings", { + get: () => { + throw new Error("The declarative adapter must not use plugin.settings"); + }, + }); + const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never); + Object.assign(tab, { + _editingSettings: { + ...DEFAULT_SETTINGS, + hashCacheMaxCount: 300, + displayLanguage: "", + }, + initialSettings: { + ...DEFAULT_SETTINGS, + hashCacheMaxCount: 300, + displayLanguage: "", + }, + }); + return { tab, saveSettingData }; + } + + it("loads the imperative fallback without a SettingPage runtime export", () => { + const { tab } = createSettingsTab(); + + expect(tab.display).toBeTypeOf("function"); + expect(tab.getSettingDefinitions()).toEqual([]); + }); + + it("reads and writes registered controls through the editing buffer and existing save owner", async () => { + const { tab } = createSettingsTab(); + const saveSettings = vi.spyOn(tab, "saveSettings").mockResolvedValue(undefined); + + expect(tab.getControlValue("hashCacheMaxCount")).toBe(300); + + await tab.setControlValue("hashCacheMaxCount", 321); + + expect(tab.editingSettings.hashCacheMaxCount).toBe(321); + expect(saveSettings).toHaveBeenCalledOnce(); + expect(saveSettings).toHaveBeenCalledWith(["hashCacheMaxCount"]); + }); + + it("rejects unregistered declarative control keys", async () => { + const { tab } = createSettingsTab(); + + expect(() => tab.getControlValue("couchDB_PASSWORD")).toThrow(/Unknown declarative setting key/u); + await expect(tab.setControlValue("couchDB_PASSWORD", "secret")).rejects.toThrow( + /Unknown declarative setting key/u + ); + }); + + it("rejects declarative values outside the registered control contract", async () => { + const { tab } = createSettingsTab(); + const saveSettings = vi.spyOn(tab, "saveSettings").mockResolvedValue(undefined); + + await expect(tab.setControlValue("hashCacheMaxCount", 9)).rejects.toThrow( + /Invalid value for declarative setting/u + ); + await expect(tab.setControlValue("chunkSplitterVersion", "unknown-splitter")).rejects.toThrow( + /Invalid value for declarative setting/u + ); + + expect(saveSettings).not.toHaveBeenCalled(); + }); + + it("replaces a saved-setting handler when a page is rendered again", async () => { + const { tab } = createSettingsTab(); + const first = vi.fn(); + const replacement = vi.fn(); + tab.addOnSaved("displayLanguage", first); + tab.addOnSaved("displayLanguage", replacement); + tab.editingSettings.displayLanguage = "ja"; + + await tab.saveSettings(["displayLanguage"]); + + expect(first).not.toHaveBeenCalled(); + expect(replacement).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/modules/features/SettingDialogue/PaneChangeLog.ts b/src/modules/features/SettingDialogue/PaneChangeLog.ts index 18808754..c3139b43 100644 --- a/src/modules/features/SettingDialogue/PaneChangeLog.ts +++ b/src/modules/features/SettingDialogue/PaneChangeLog.ts @@ -6,7 +6,8 @@ const updateInformation: string = UPDATE_INFO || ""; export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement): void { const informationDivEl = this.createEl(paneEl, "div", { text: "" }); + const lifetimeComponent = this.lifetimeComponent; fireAndForget(() => - MarkdownRenderer.render(this.plugin.app, updateInformation, informationDivEl, "/", this.lifetimeComponent) + MarkdownRenderer.render(this.plugin.app, updateInformation, informationDivEl, "/", lifetimeComponent) ); } diff --git a/src/modules/features/SettingDialogue/PaneGeneral.ts b/src/modules/features/SettingDialogue/PaneGeneral.ts index 9fcc36ca..6d3ab1c0 100644 --- a/src/modules/features/SettingDialogue/PaneGeneral.ts +++ b/src/modules/features/SettingDialogue/PaneGeneral.ts @@ -15,7 +15,7 @@ export function paneGeneral(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen new Setting(paneEl).autoWireDropDown("displayLanguage", { options: languages, }); - this.addOnSaved("displayLanguage", () => this.display()); + this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh()); new Setting(paneEl).autoWireToggle("showStatusOnEditor"); this.addOnSaved("showStatusOnEditor", () => { eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index 16118413..376157ec 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -24,7 +24,7 @@ import { import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts"; import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; +import { setButtonDestructiveState, type PageFunctions } from "./SettingPane.ts"; import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { chooseAndCopyFileDatabaseInfo, @@ -1097,10 +1097,9 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, .setName("Check and convert non-path-obfuscated files") .setDesc("") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Perform") .setDisabled(false) - .setWarning() .onClick(async () => { for await (const docName of this.core.localDatabase.findAllDocNames()) { if (!docName.startsWith("f:")) { @@ -1185,10 +1184,9 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, ); new Setting(paneEl).setName("Delete all customization sync data").addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Delete") .setDisabled(false) - .setWarning() .onClick(async () => { Logger(`Deleting customization sync data`, LOG_LEVEL_NOTICE); const entriesToDelete = await this.core.localDatabase.allDocsRaw({ diff --git a/src/modules/features/SettingDialogue/PaneMaintenance.ts b/src/modules/features/SettingDialogue/PaneMaintenance.ts index 47fd1424..828cd286 100644 --- a/src/modules/features/SettingDialogue/PaneMaintenance.ts +++ b/src/modules/features/SettingDialogue/PaneMaintenance.ts @@ -5,7 +5,7 @@ import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab"; -import { visibleOnly, type PageFunctions } from "./SettingPane"; +import { setButtonDestructiveState, visibleOnly, type PageFunctions } from "./SettingPane"; export function paneMaintenance( this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, @@ -33,7 +33,7 @@ export function paneMaintenance( e.addEventListener("click", () => { fireAndForget(async () => { await this.services.replication.markResolved(); - this.display(); + this.requestPageRefresh(); }); }); } @@ -60,7 +60,7 @@ export function paneMaintenance( e.addEventListener("click", () => { fireAndForget(async () => { await this.services.replication.markUnlocked(); - this.display(); + this.requestPageRefresh(); }); }); } @@ -73,10 +73,9 @@ export function paneMaintenance( .setName("Lock Server") .setDesc("Lock the remote server to prevent synchronization with other devices.") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Lock") .setDisabled(false) - .setWarning() .onClick(async () => { await this.services.replication.markLocked(); }) @@ -87,10 +86,9 @@ export function paneMaintenance( .setName("Emergency restart") .setDesc("Disables all synchronization and restart.") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Flag and restart") .setDisabled(false) - .setWarning() .onClick(async () => { await this.core.storageAccess.writeFileAuto(FLAGMD_REDFLAG, ""); this.services.appLifecycle.performRestart(); @@ -132,9 +130,8 @@ export function paneMaintenance( .setName("Resend") .setDesc("Resend all chunks to the remote.") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Send chunks") - .setWarning() .setDisabled(false) .onClick(async () => { if (this.core.replicator instanceof LiveSyncCouchDBReplicator) { @@ -150,9 +147,8 @@ export function paneMaintenance( "Initialise journal received history. On the next sync, every item except this device sent will be downloaded again." ) .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Reset received") - .setWarning() .setDisabled(false) .onClick(async () => { await this.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ @@ -171,9 +167,8 @@ export function paneMaintenance( "Initialise journal sent history. On the next sync, every item except this device received will be sent again." ) .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Reset sent history") - .setWarning() .setDisabled(false) .onClick(async () => { await this.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ @@ -314,9 +309,8 @@ export function paneMaintenance( .setName("Overwrite remote") .setDesc("Overwrite remote with local DB and passphrase.") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Send") - .setWarning() .setDisabled(false) .onClick(async () => { await this.rebuildDB("remoteOnly"); @@ -327,9 +321,8 @@ export function paneMaintenance( .setName("Reset all journal counter") .setDesc("Initialise all journal history, On the next sync, every item will be received and sent.") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Reset all") - .setWarning() .setDisabled(false) .onClick(async () => { await this.getMinioJournalSyncClient().resetCheckpointInfo(); @@ -342,9 +335,8 @@ export function paneMaintenance( .setName("Purge all journal counter") .setDesc("Purge all download/upload cache.") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Reset all") - .setWarning() .setDisabled(false) .onClick(() => { this.getMinioJournalSyncClient().resetAllCaches(); @@ -357,9 +349,8 @@ export function paneMaintenance( .setName("Fresh Start Wipe") .setDesc("Delete all data on the remote server.") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Delete") - .setWarning() .setDisabled(false) .onClick(async () => { await this.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ @@ -381,9 +372,8 @@ export function paneMaintenance( new Setting(paneEl) .setName("Delete local database to reset or uninstall Self-hosted LiveSync") .addButton((button) => - button + setButtonDestructiveState(button) .setButtonText("Delete") - .setWarning() .setDisabled(false) .onClick(async () => { await this.services.database.resetDatabase(); diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts index 4e11996d..bd2143ef 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts @@ -11,7 +11,7 @@ import { Menu, type ButtonComponent } from "@/deps.ts"; import { $msg } from "@/common/translation"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; +import { setButtonDestructiveState, type PageFunctions } from "./SettingPane.ts"; // import { visibleOnly } from "./SettingPane.ts"; import InfoPanel from "./InfoPanel.svelte"; import { writable } from "svelte/store"; @@ -103,11 +103,12 @@ export function paneRemoteConfig( }); }; void addPanel(paneEl, "E2EE Configuration", () => {}).then((paneEl) => { - new SveltePanel(InfoPanel, paneEl, E2EESummaryWritable); + const infoPanel = new SveltePanel(InfoPanel, paneEl, E2EESummaryWritable); + this.lifetimeComponent.register(() => infoPanel.destroy()); const setupButton = new Setting(paneEl).setName("Configure E2EE"); setupButton .addButton((button) => - button + setButtonDestructiveState(button) .onClick(async () => { const setupManager = this.core.getModule(SetupManager); const originalSettings = getSettingsFromEditingSettings(this.editingSettings); @@ -115,10 +116,9 @@ export function paneRemoteConfig( updateE2EESummary(); }) .setButtonText("Configure") - .setWarning() ) .addButton((button) => - button + setButtonDestructiveState(button) .onClick(async () => { const setupManager = this.core.getModule(SetupManager); const originalSettings = getSettingsFromEditingSettings(this.editingSettings); @@ -126,7 +126,6 @@ export function paneRemoteConfig( updateE2EESummary(); }) .setButtonText("Configure And Change Remote") - .setWarning() ); updateE2EESummary(); }); diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts new file mode 100644 index 00000000..a11f20f7 --- /dev/null +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.unit.spec.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const runtime = vi.hoisted(() => ({ + panels: [] as Array<{ destroy: ReturnType }>, +})); + +vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({ + DEFAULT_SETTINGS: {}, + LOG_LEVEL_NOTICE: 1, + LOG_LEVEL_VERBOSE: 2, + REMOTE_COUCHDB: "couchdb", + REMOTE_MINIO: "minio", + REMOTE_P2P: "p2p", +})); +vi.mock("@/deps.ts", () => ({ + Menu: class {}, +})); +vi.mock("@/common/translation", () => ({ + $msg: (message: string) => message, +})); +vi.mock("./LiveSyncSetting.ts", () => ({ + LiveSyncSetting: class { + nameEl = { addClass: vi.fn(), appendText: vi.fn() }; + + setName() { + return this; + } + + setDesc() { + return this; + } + + addButton() { + return this; + } + + autoWireNumeric() { + return this; + } + }, +})); +vi.mock("./InfoPanel.svelte", () => ({ default: {} })); +vi.mock("./SveltePanel.ts", () => ({ + SveltePanel: class { + destroy = vi.fn(); + + constructor() { + runtime.panels.push(this); + } + }, +})); +vi.mock("./settingUtils.ts", () => ({ + getE2EEConfigSummary: vi.fn(() => ({ summary: "summary" })), +})); +vi.mock("@/modules/features/SetupManager.ts", () => ({ + SetupManager: class {}, + UserMode: { Update: "update" }, +})); +vi.mock("./settingConstants.ts", () => ({ + OnDialogSettingsDefault: {}, +})); +vi.mock("@vrtmrz/livesync-commonlib/remote-configurations", () => ({ + activateRemoteConfiguration: vi.fn(), +})); +vi.mock("@vrtmrz/livesync-commonlib/compat/common/ConnectionString", () => ({ + ConnectionStringParser: { + parse: vi.fn(), + serialize: vi.fn(() => ""), + }, +})); +vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} })); +vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} })); +vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} })); +vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} })); +vi.mock("./remoteConfigBuffer.ts", () => ({ + syncActivatedRemoteSettings: vi.fn(), +})); + +import { paneRemoteConfig } from "./PaneRemoteConfig.ts"; + +function createPanelElement(): HTMLElement { + return { + createDiv: vi.fn(() => ({ empty: vi.fn() })), + } as unknown as HTMLElement; +} + +afterEach(() => { + runtime.panels.length = 0; + vi.clearAllMocks(); +}); + +describe("paneRemoteConfig", () => { + it("destroys the E2EE info panel when the settings page lifetime unloads", async () => { + const callbacks: Array<() => unknown> = []; + const lifetimeComponent = { + register: vi.fn((callback: () => unknown) => callbacks.push(callback)), + unload: vi.fn(() => callbacks.splice(0).forEach((callback) => callback())), + }; + const addPanel = vi.fn((_parent: HTMLElement, _heading: string) => Promise.resolve(createPanelElement())); + const host = { + editingSettings: { remoteConfigurations: {} }, + core: { settings: { remoteConfigurations: {} } }, + lifetimeComponent, + }; + + paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never); + await vi.waitFor(() => expect(runtime.panels).toHaveLength(1)); + + lifetimeComponent.unload(); + + expect(runtime.panels[0].destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/modules/features/SettingDialogue/PaneSelector.ts b/src/modules/features/SettingDialogue/PaneSelector.ts index 15594e9d..3a6f8e4b 100644 --- a/src/modules/features/SettingDialogue/PaneSelector.ts +++ b/src/modules/features/SettingDialogue/PaneSelector.ts @@ -2,7 +2,7 @@ import { LEVEL_ADVANCED, type CustomRegExpSource } from "@vrtmrz/livesync-common import { constructCustomRegExpList, splitCustomRegExpList } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import MultipleRegExpControl from "./MultipleRegExpControl.svelte"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; -import { mount } from "svelte"; +import { mount, unmount } from "svelte"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { PageFunctions } from "./SettingPane.ts"; import { visibleOnly } from "./SettingPane.ts"; @@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme .setDesc( "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files." ); - mount(MultipleRegExpControl, { + const syncFilesControl = mount(MultipleRegExpControl, { target: syncFilesSetting.controlEl, props: { patterns: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"), @@ -21,16 +21,17 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme apply: async (newPatterns: CustomRegExpSource[]) => { this.editingSettings.syncOnlyRegEx = constructCustomRegExpList(newPatterns, "|[]|"); await this.saveAllDirtySettings(); - this.display(); + this.requestPageRefresh(); }, }, }); + this.lifetimeComponent.register(() => void unmount(syncFilesControl)); const nonSyncFilesSetting = new Setting(paneEl) .setName("Non-Synchronising files") .setDesc("(RegExp) If this is set, any changes to local and remote files that match this will be skipped."); - mount(MultipleRegExpControl, { + const nonSyncFilesControl = mount(MultipleRegExpControl, { target: nonSyncFilesSetting.controlEl, props: { patterns: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"), @@ -38,10 +39,11 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme apply: async (newPatterns: CustomRegExpSource[]) => { this.editingSettings.syncIgnoreRegEx = constructCustomRegExpList(newPatterns, "|[]|"); await this.saveAllDirtySettings(); - this.display(); + this.requestPageRefresh(); }, }, }); + this.lifetimeComponent.register(() => void unmount(nonSyncFilesControl)); new Setting(paneEl).autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 }); new Setting(paneEl).autoWireToggle("useIgnoreFiles"); @@ -54,7 +56,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme .setName("Target patterns") .setDesc("Patterns to match files for syncing"); const patTarget = splitCustomRegExpList(this.editingSettings.syncInternalFilesTargetPatterns, ","); - mount(MultipleRegExpControl, { + const targetPatternControl = mount(MultipleRegExpControl, { target: targetPatternSetting.controlEl, props: { patterns: patTarget, @@ -62,10 +64,11 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme apply: async (newPatterns: CustomRegExpSource[]) => { this.editingSettings.syncInternalFilesTargetPatterns = constructCustomRegExpList(newPatterns, ","); await this.saveAllDirtySettings(); - this.display(); + this.requestPageRefresh(); }, }, }); + this.lifetimeComponent.register(() => void unmount(targetPatternControl)); const defaultSkipPattern = "\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/"; const defaultSkipPatternXPlat = @@ -74,7 +77,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme const pat = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ","); const patSetting = new Setting(paneEl).setName("Ignore patterns").setDesc(""); - mount(MultipleRegExpControl, { + const ignorePatternControl = mount(MultipleRegExpControl, { target: patSetting.controlEl, props: { patterns: pat, @@ -82,10 +85,11 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme apply: async (newPatterns: CustomRegExpSource[]) => { this.editingSettings.syncInternalFilesIgnorePatterns = constructCustomRegExpList(newPatterns, ","); await this.saveAllDirtySettings(); - this.display(); + this.requestPageRefresh(); }, }, }); + this.lifetimeComponent.register(() => void unmount(ignorePatternControl)); const addDefaultPatterns = async (patterns: string) => { const oldList = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ","); @@ -96,7 +100,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme const allSet = new Set([...oldList, ...newList]); this.editingSettings.syncInternalFilesIgnorePatterns = constructCustomRegExpList([...allSet], ","); await this.saveAllDirtySettings(); - this.display(); + this.requestPageRefresh(); }; new Setting(paneEl) @@ -116,7 +120,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme .setName("Overwrite patterns") .setDesc("Patterns to match files for overwriting instead of merging"); const patTarget2 = splitCustomRegExpList(this.editingSettings.syncInternalFileOverwritePatterns, ","); - mount(MultipleRegExpControl, { + const overwritePatternControl = mount(MultipleRegExpControl, { target: overwritePatterns.controlEl, props: { patterns: patTarget2, @@ -127,9 +131,10 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme "," ); await this.saveAllDirtySettings(); - this.display(); + this.requestPageRefresh(); }, }, }); + this.lifetimeComponent.register(() => void unmount(overwritePatternControl)); }); } diff --git a/src/modules/features/SettingDialogue/PaneSetup.ts b/src/modules/features/SettingDialogue/PaneSetup.ts index 1fd66675..485864fa 100644 --- a/src/modules/features/SettingDialogue/PaneSetup.ts +++ b/src/modules/features/SettingDialogue/PaneSetup.ts @@ -9,8 +9,7 @@ import { eventHub, } from "@/common/events.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; -import type { PageFunctions } from "./SettingPane.ts"; -import { visibleOnly } from "./SettingPane.ts"; +import { setButtonDestructiveState, visibleOnly, type PageFunctions } from "./SettingPane.ts"; import { request } from "@/deps.ts"; import { SetupManager } from "@/modules/features/SetupManager.ts"; import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"; @@ -86,7 +85,8 @@ export function paneSetup( new Setting(paneEl) .setName($msg("obsidianLiveSyncSettingTab.nameDiscardSettings")) .addButton((text) => { - text.setButtonText($msg("obsidianLiveSyncSettingTab.btnDiscard")) + setButtonDestructiveState(text) + .setButtonText($msg("obsidianLiveSyncSettingTab.btnDiscard")) .onClick(async () => { if ( (await this.core.confirm.askYesNoDialog( @@ -102,8 +102,7 @@ export function paneSetup( // await this.plugin.initializeDatabase(); this.services.appLifecycle.askRestart(); } - }) - .setWarning(); + }); }) .addOnUpdate(visibleOnly(() => this.isConfiguredAs("isConfigured", true))); }); @@ -114,12 +113,17 @@ export function paneSetup( new Setting(paneEl).autoWireToggle("usePowerUserMode"); new Setting(paneEl).autoWireToggle("useEdgeCaseMode"); - this.addOnSaved("useAdvancedMode", () => this.display()); - this.addOnSaved("usePowerUserMode", () => this.display()); - this.addOnSaved("useEdgeCaseMode", () => this.display()); + this.addOnSaved("useAdvancedMode", () => this.requestCatalogueRefresh()); + this.addOnSaved("usePowerUserMode", () => this.requestCatalogueRefresh()); + this.addOnSaved("useEdgeCaseMode", () => this.requestCatalogueRefresh()); }); void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleOnlineTips")).then((paneEl) => { + const lifetimeComponent = this.lifetimeComponent; + let pageDisposed = false; + lifetimeComponent.register(() => { + pageDisposed = true; + }); // this.createEl(paneEl, "h3", { text: $msg("obsidianLiveSyncSettingTab.titleOnlineTips") }); const repo = "vrtmrz/obsidian-livesync"; const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting"); @@ -152,6 +156,7 @@ export function paneSetup( const err = LiveSyncError.fromError(ex); remoteTroubleShootMDSrc = `${$msg("obsidianLiveSyncSettingTab.logErrorOccurred")}\n${err.toString()}`; } + if (pageDisposed) return; const remoteTroubleShootMD = remoteTroubleShootMDSrc.replace( /\((.*?(.png)|(.jpg))\)/g, `(${rawRepoURI}${basePath}/$1)` @@ -162,8 +167,9 @@ export function paneSetup( ` [${$msg("obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting")}](${topPath}) [${$msg("obsidianLiveSyncSettingTab.linkPageTop")}](${filename})\n\n${remoteTroubleShootMD}`, troubleShootEl, `${rawRepoURI}`, - this.lifetimeComponent + lifetimeComponent ); + if (pageDisposed) return; // Menu troubleShootEl.querySelector(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({ position: "sticky", diff --git a/src/modules/features/SettingDialogue/PaneSyncSettings.ts b/src/modules/features/SettingDialogue/PaneSyncSettings.ts index e7ced243..13ccce1f 100644 --- a/src/modules/features/SettingDialogue/PaneSyncSettings.ts +++ b/src/modules/features/SettingDialogue/PaneSyncSettings.ts @@ -237,7 +237,7 @@ export function paneSyncSettings( button.setButtonText($msg("obsidianLiveSyncSettingTab.btnDisable")).onClick(async () => { this.editingSettings.syncInternalFiles = false; await this.saveAllDirtySettings(); - this.display(); + this.requestPageRefresh(); }); }); } else { diff --git a/src/modules/features/SettingDialogue/SettingPane.ts b/src/modules/features/SettingDialogue/SettingPane.ts index 7644d6fd..a21a7e4c 100644 --- a/src/modules/features/SettingDialogue/SettingPane.ts +++ b/src/modules/features/SettingDialogue/SettingPane.ts @@ -6,6 +6,7 @@ import { type ConfigLevel, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { AllSettingItemKey, AllSettings } from "./settingConstants"; +import type { ButtonComponent } from "@/deps.ts"; export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => { return () => ({ @@ -38,6 +39,25 @@ export function setStyle(el: HTMLElement, styleHead: string, condition: () => bo } } +/** + * Applies destructive-action styling without requiring Obsidian 1.13 at + * runtime. Older supported versions used the `mod-warning` class for the same + * presentation. + */ +export function setButtonDestructiveState(button: ButtonComponent, isDestructive = true): ButtonComponent { + const compatibleButton = button as unknown as { + setDestructive?: () => ButtonComponent; + removeDestructive?: () => ButtonComponent; + }; + const updateNativeStyle = isDestructive ? compatibleButton.setDestructive : compatibleButton.removeDestructive; + if (typeof updateNativeStyle === "function") { + updateNativeStyle.call(button); + } else { + button.buttonEl.classList.toggle("mod-warning", isDestructive); + } + return button; +} + export function visibleOnly(cond: () => boolean): OnUpdateFunc { return () => ({ visibility: cond(), @@ -106,6 +126,15 @@ export function wrapMemo(func: (arg: T) => void) { } }; } + +/** + * Defers pane construction until the owning settings page can confirm that its + * render scope is still active. + */ +export type DeferredPageElement = { + then(callback: (value: T) => unknown): void; +}; + export type PageFunctions = { addPane: ( parentEl: HTMLElement, @@ -113,12 +142,12 @@ export type PageFunctions = { icon: string, order: number, level?: ConfigLevel - ) => Promise; + ) => DeferredPageElement; addPanel: ( parentEl: HTMLElement, title: string, callback?: (el: HTMLDivElement) => void, func?: OnUpdateFunc, level?: ConfigLevel - ) => Promise; + ) => DeferredPageElement; }; diff --git a/src/modules/features/SettingDialogue/SettingPane.unit.spec.ts b/src/modules/features/SettingDialogue/SettingPane.unit.spec.ts new file mode 100644 index 00000000..be208eb8 --- /dev/null +++ b/src/modules/features/SettingDialogue/SettingPane.unit.spec.ts @@ -0,0 +1,44 @@ +import type { ButtonComponent } from "@/deps.ts"; +import { describe, expect, it, vi } from "vitest"; +import { setButtonDestructiveState } from "./SettingPane.ts"; + +type CompatibleButton = ButtonComponent & { + setDestructive?: () => ButtonComponent; + removeDestructive?: () => ButtonComponent; +}; + +function createButton(overrides: Partial = {}): CompatibleButton { + return { + buttonEl: { + classList: { + toggle: vi.fn(), + }, + }, + ...overrides, + } as unknown as CompatibleButton; +} + +describe("setButtonDestructiveState", () => { + it("uses the native destructive-button API when it is available", () => { + const setDestructive = vi.fn(); + const removeDestructive = vi.fn(); + const button = createButton({ setDestructive, removeDestructive }); + + expect(setButtonDestructiveState(button, true)).toBe(button); + expect(setButtonDestructiveState(button, false)).toBe(button); + + expect(setDestructive).toHaveBeenCalledOnce(); + expect(removeDestructive).toHaveBeenCalledOnce(); + expect(button.buttonEl.classList.toggle).not.toHaveBeenCalled(); + }); + + it("uses the legacy warning class when the native API is unavailable", () => { + const button = createButton(); + + setButtonDestructiveState(button, true); + setButtonDestructiveState(button, false); + + expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(1, "mod-warning", true); + expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(2, "mod-warning", false); + }); +}); diff --git a/src/modules/features/SettingDialogue/SettingSpec.ts b/src/modules/features/SettingDialogue/SettingSpec.ts index dad1d39f..a14c6adb 100644 --- a/src/modules/features/SettingDialogue/SettingSpec.ts +++ b/src/modules/features/SettingDialogue/SettingSpec.ts @@ -169,6 +169,18 @@ function numberIsOutOfRange(value: number, control: NumberSettingSpec["control"] return control.min !== undefined && value < control.min; } +/** Check a value at the settings-tab persistence boundary against its shared control specification. */ +export function isValidSettingSpecValue(spec: SettingSpec, value: unknown): boolean { + switch (spec.control.type) { + case "toggle": + return typeof value === "boolean"; + case "number": + return typeof value === "number" && !numberIsOutOfRange(value, spec.control); + case "dropdown": + return typeof value === "string" && Object.prototype.hasOwnProperty.call(spec.control.options(), value); + } +} + /** * Convert one shared specification to a declarative Obsidian control. * diff --git a/src/modules/features/SettingDialogue/SettingsPageCatalogue.ts b/src/modules/features/SettingDialogue/SettingsPageCatalogue.ts new file mode 100644 index 00000000..c8c6b14d --- /dev/null +++ b/src/modules/features/SettingDialogue/SettingsPageCatalogue.ts @@ -0,0 +1,191 @@ +import { $msg } from "@/common/translation"; +import { + LEVEL_ADVANCED, + LEVEL_EDGE_CASE, + LEVEL_POWER_USER, + type ConfigLevel, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { SettingDefinitionGroup } from "obsidian"; +import { createAdvancedSettingSpecGroups, type AdvancedSettingSpecContext } from "./AdvancedSettingSpecs.ts"; +import { toObsidianSettingDefinition, type PersistedSettingKey, type SettingSpec } from "./SettingSpec.ts"; +import { getConfig } from "./settingConstants.ts"; +import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; +import type { PageFunctions } from "./SettingPane.ts"; +import { paneAdvanced } from "./PaneAdvanced.ts"; +import { paneChangeLog } from "./PaneChangeLog.ts"; +import { paneCustomisationSync } from "./PaneCustomisationSync.ts"; +import { paneGeneral } from "./PaneGeneral.ts"; +import { paneHatch } from "./PaneHatch.ts"; +import { paneMaintenance } from "./PaneMaintenance.ts"; +import { panePatches } from "./PanePatches.ts"; +import { panePowerUsers } from "./PanePowerUsers.ts"; +import { paneRemoteConfig } from "./PaneRemoteConfig.ts"; +import { paneSelector } from "./PaneSelector.ts"; +import { paneSetup } from "./PaneSetup.ts"; +import { paneSyncSettings } from "./PaneSyncSettings.ts"; + +/** The existing pane renderer used by the imperative settings tab and custom pages. */ +export type SettingsPaneRenderer = ( + this: ObsidianLiveSyncSettingTab, + paneEl: HTMLElement, + functions: PageFunctions +) => void; + +export type SettingsPageContent = "native" | "custom"; + +/** One established settings page, shared by the imperative and declarative renderers. */ +export type SettingsPageEntry = { + id: string; + name: () => string; + icon: string; + order: number; + level?: ConfigLevel; + content: SettingsPageContent; + legacy: SettingsPaneRenderer; +}; + +/** + * Build the explicit page list in the same order as the existing settings tab. + * + * Names remain functions so a catalogue refresh observes the current language, + * while constructing the catalogue itself performs no rendering or persistence. + */ +export function createSettingsPageCatalogue(): SettingsPageEntry[] { + return [ + { + id: "change-log", + name: () => $msg("obsidianLiveSyncSettingTab.panelChangeLog"), + icon: "💬", + order: 100, + level: undefined, + content: "custom", + legacy: paneChangeLog, + }, + { + id: "setup", + name: () => $msg("obsidianLiveSyncSettingTab.panelSetup"), + icon: "🧙‍♂️", + order: 110, + level: undefined, + content: "custom", + legacy: paneSetup, + }, + { + id: "general", + name: () => $msg("obsidianLiveSyncSettingTab.panelGeneralSettings"), + icon: "⚙️", + order: 20, + level: undefined, + content: "custom", + legacy: paneGeneral, + }, + { + id: "remote-configuration", + name: () => $msg("obsidianLiveSyncSettingTab.panelRemoteConfiguration"), + icon: "🛰️", + order: 0, + level: undefined, + content: "custom", + legacy: paneRemoteConfig, + }, + { + id: "synchronisation", + name: () => $msg("obsidianLiveSyncSettingTab.titleSyncSettings"), + icon: "🔄", + order: 30, + level: undefined, + content: "custom", + legacy: paneSyncSettings, + }, + { + id: "selector", + name: () => "Selector", + icon: "🚦", + order: 33, + level: LEVEL_ADVANCED, + content: "custom", + legacy: paneSelector, + }, + { + id: "customisation-sync", + name: () => "Customisation sync", + icon: "🔌", + order: 60, + level: LEVEL_ADVANCED, + content: "custom", + legacy: paneCustomisationSync, + }, + { + id: "hatch", + name: () => "Hatch", + icon: "🧰", + order: 50, + level: undefined, + content: "custom", + legacy: paneHatch, + }, + { + id: "advanced", + name: () => "Advanced", + icon: "🔧", + order: 46, + level: LEVEL_ADVANCED, + content: "native", + legacy: paneAdvanced, + }, + { + id: "power-users", + name: () => "Power users", + icon: "💪", + order: 47, + level: LEVEL_POWER_USER, + content: "custom", + legacy: panePowerUsers, + }, + { + id: "patches", + name: () => "Patches", + icon: "🩹", + order: 51, + level: LEVEL_EDGE_CASE, + content: "custom", + legacy: panePatches, + }, + { + id: "maintenance", + name: () => "Maintenance", + icon: "🎛️", + order: 70, + level: undefined, + content: "custom", + legacy: paneMaintenance, + }, + ]; +} + +const numberRangeMessage = ({ min, max }: { min?: number; max?: number }): string => + $msg("liveSyncSetting.valueShouldBeInRange", { + min: min === undefined ? "~" : `${min}`, + max: max === undefined ? "~" : `${max}`, + }); + +function toAdvancedSettingDefinition(spec: SettingSpec): ReturnType { + const metadata = getConfig(spec.key); + if (!metadata) { + throw new Error(`Missing translated setting metadata for ${spec.key}`); + } + return toObsidianSettingDefinition(spec, metadata, { + valueShouldBeInRange: numberRangeMessage, + }); +} + +/** Convert the existing Advanced specifications to native Obsidian groups. */ +export function createAdvancedSettingDefinitionGroups( + context: AdvancedSettingSpecContext +): SettingDefinitionGroup[] { + return createAdvancedSettingSpecGroups(context).map((group) => ({ + type: "group", + heading: group.heading, + items: group.items.map(toAdvancedSettingDefinition), + })); +} diff --git a/src/modules/features/SettingDialogue/SettingsPageCatalogue.unit.spec.ts b/src/modules/features/SettingDialogue/SettingsPageCatalogue.unit.spec.ts new file mode 100644 index 00000000..aed64d20 --- /dev/null +++ b/src/modules/features/SettingDialogue/SettingsPageCatalogue.unit.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/common/translation", () => ({ + $msg: (key: string) => key, + translateLiveSyncMessage: (key: string) => key, +})); +vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() })); +vi.mock("./PaneSetup.ts", () => ({ paneSetup: vi.fn() })); +vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: vi.fn() })); +vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() })); +vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() })); +vi.mock("./PaneSyncSettings.ts", () => ({ paneSyncSettings: vi.fn() })); +vi.mock("./PaneCustomisationSync.ts", () => ({ paneCustomisationSync: vi.fn() })); +vi.mock("./PaneHatch.ts", () => ({ paneHatch: vi.fn() })); +vi.mock("./PaneAdvanced.ts", () => ({ paneAdvanced: vi.fn() })); +vi.mock("./PanePowerUsers.ts", () => ({ panePowerUsers: vi.fn() })); +vi.mock("./PanePatches.ts", () => ({ panePatches: vi.fn() })); +vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() })); + +import { createAdvancedSettingDefinitionGroups, createSettingsPageCatalogue } from "./SettingsPageCatalogue.ts"; + +describe("settings page catalogue", () => { + it("registers every existing page once and keeps only Advanced native", () => { + const catalogue = createSettingsPageCatalogue(); + + expect(catalogue.map(({ id }) => id)).toEqual([ + "change-log", + "setup", + "general", + "remote-configuration", + "synchronisation", + "selector", + "customisation-sync", + "hatch", + "advanced", + "power-users", + "patches", + "maintenance", + ]); + expect(new Set(catalogue.map(({ id }) => id)).size).toBe(catalogue.length); + expect(new Set(catalogue.map(({ name }) => name())).size).toBe(catalogue.length); + expect(catalogue.filter(({ content }) => content === "native").map(({ id }) => id)).toEqual(["advanced"]); + expect(catalogue.filter(({ content }) => content === "custom")).toHaveLength(11); + }); + + it("registers each Advanced control key exactly once", () => { + const groups = createAdvancedSettingDefinitionGroups({ isCouchDB: () => true }); + const keys = groups.flatMap(({ items = [] }) => + items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : [])) + ); + + expect(keys).toEqual([ + "hashCacheMaxCount", + "chunkSplitterVersion", + "customChunkSize", + "readChunksOnline", + "useOnlyLocalChunk", + "concurrencyOfReadChunksOnline", + "minimumIntervalOfReadChunksOnline", + "autoAcceptCompatibleTweak", + "enableCompression", + ]); + expect(new Set(keys).size).toBe(keys.length); + }); +});