mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-27 05:47:07 +00:00
Adapt settings pages to Obsidian's declarative API
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
+328
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user