Adapt settings pages to Obsidian's declarative API

This commit is contained in:
vorotamoroz
2026-08-24 12:17:19 +00:00
parent d8f7762d01
commit 47759f6205
20 changed files with 1327 additions and 185 deletions
@@ -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.
+1
View File
@@ -16,6 +16,7 @@ export {
requestUrl,
sanitizeHTMLToDom,
Setting,
SettingPage,
stringifyYaml,
TAbstractFile,
TextAreaComponent,
@@ -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];
}
@@ -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<typeof vi.fn>;
unload: ReturnType<typeof vi.fn>;
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<PageFunctions, "addPanel">
) {
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<PageFunctions, "addPanel">
) {
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();
});
});
@@ -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<string>();
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<T extends AllSettingItemKey>(key: T, func: OnSavedHandlerFunc<T>) {
const newHandler = { key, handler: func } as OnSavedHandler<AllSettingItemKey>;
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<void> {
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<T extends HTMLElement>(value: T, owner: Component): DeferredPageElement<T> {
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 == "") {
@@ -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();
});
});
@@ -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)
);
}
@@ -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);
@@ -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({
@@ -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();
@@ -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();
});
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
}));
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();
});
});
@@ -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<CustomRegExpSource>([...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));
});
}
@@ -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(
`<a class='sls-troubleshoot-anchor'></a> [${$msg("obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting")}](${topPath}) [${$msg("obsidianLiveSyncSettingTab.linkPageTop")}](${filename})\n\n${remoteTroubleShootMD}`,
troubleShootEl,
`${rawRepoURI}`,
this.lifetimeComponent
lifetimeComponent
);
if (pageDisposed) return;
// Menu
troubleShootEl.querySelector<HTMLAnchorElement>(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({
position: "sticky",
@@ -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 {
@@ -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<T>(func: (arg: T) => void) {
}
};
}
/**
* Defers pane construction until the owning settings page can confirm that its
* render scope is still active.
*/
export type DeferredPageElement<T extends HTMLElement = HTMLDivElement> = {
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<HTMLDivElement>;
) => DeferredPageElement;
addPanel: (
parentEl: HTMLElement,
title: string,
callback?: (el: HTMLDivElement) => void,
func?: OnUpdateFunc,
level?: ConfigLevel
) => Promise<HTMLDivElement>;
) => DeferredPageElement;
};
@@ -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> = {}): 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);
});
});
@@ -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.
*
@@ -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<typeof toObsidianSettingDefinition> {
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<PersistedSettingKey>[] {
return createAdvancedSettingSpecGroups(context).map((group) => ({
type: "group",
heading: group.heading,
items: group.items.map(toAdvancedSettingDefinition),
}));
}
@@ -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);
});
});