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