diff --git a/docs/adr/2026_08_declarative_settings_adapter.md b/docs/adr/2026_08_declarative_settings_adapter.md index e48bb9b4..6ee9cfe0 100644 --- a/docs/adr/2026_08_declarative_settings_adapter.md +++ b/docs/adr/2026_08_declarative_settings_adapter.md @@ -1,5 +1,5 @@ --- -date: 2026-08-24 +date: 2026-08-25 commonlib-version: "0.1.19" self-hosted-livesync-version: "1.0.18" status: accepted @@ -9,10 +9,10 @@ status: accepted ## Status -Accepted and implemented through Stage C1. The implementation is deliberately -limited to one-key, immediately persisted controls and one proof page. It does -not attempt to describe every existing settings interaction through a new -abstraction. Stage C2 remains an optional, page-by-page improvement. +Accepted and implemented through Stage C1 and two bounded Stage C2 +landing-page improvements. The shared specification remains deliberately +limited to one-key, immediately persisted controls. Complex pages retain their +existing renderers instead of being forced through a general abstraction. ## Context @@ -42,7 +42,7 @@ responsibilities: which run after a successful save. These responsibilities are not all declarative setting data. In particular, -Remote Configuration, Hatch, Maintenance, Setup, and Selector contain dynamic +Remote Configuration, Hatch, Maintenance, Help, and Selector contain dynamic lists, Svelte components, diagnostic results, multi-step actions, and destructive confirmations. Encoding those interactions in a general settings DSL would increase the abstraction before a second renderer had proved which @@ -140,6 +140,51 @@ by the catalogue, while the imperative renderer continues to pass the same emoji to its existing menu button. This preserves the established visual identity without adding host-DOM manipulation. +### Compose the native landing page around common tasks + +The declarative root is a composition of native groups and catalogue pages, +not a second flat copy of the legacy tab menu. General Settings contains the +native Appearance, Logging, and Extra menus child pages. Their standard +`SettingSpec` controls remain searchable without crowding the root. The small +Quick Setup actions are native action rows on the root. The old Setup child +page is not retained: its feature-level controls move to Extra menus, its full +reset moves to Maintenance, and its online guidance becomes Help and +troubleshooting. The pane-based interface exposes Quick Setup as a pane and +renders the same controls within General Settings. + +Remote Configuration and Sync Settings remain catalogue pages. Obsidian's +native group contract permits navigable pages as group items, so both pages are +placed inside an explicit Synchronisation group. This keeps them near the top +for narrow mobile displays while preventing the unheaded page entries from +appearing to continue the preceding Quick Setup group. The root order reflects +the current task: + +| Current state | First root sections | +| --------------------------- | ----------------------------------------------------------------------------------- | +| Synchronisation is inactive | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General | +| Synchronisation is active | Synchronisation (Remote Configuration and Sync Settings), General, then Quick Setup | + +Set up other devices follows the Quick Setup and General groups when the +plug-in is configured. The remaining destinations are grouped explicitly: + +| Group | Pages | +| ------------------------ | ---------------------------------------- | +| Maintenance and recovery | Maintenance and Hatch | +| Extra features | Selector and Customisation sync | +| Advanced settings | Advanced, Power users, and Patches | +| Help and information | Help and troubleshooting, and Change Log | + +This prevents Obsidian from presenting them as one undifferentiated 'Detailed +settings' continuation. Changing a setting which can move or reveal a page +requests a catalogue refresh after persistence. External setting reloads use +the same boundary. Constructing the definitions still performs no persistence, +service, file, database, or network operation. + +The imperative renderer retains its existing default-page selection: Quick Setup for +an inactive configuration and General for an active configuration. The landing +composition is therefore a native 1.13 improvement rather than a behaviour +change for earlier supported Obsidian versions. + 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 @@ -267,8 +312,7 @@ custom row or custom page: - password inputs; - a control which maps one displayed value to several stored keys, such as `syncMode`; -- a control with an `onSaved` service, event, restart, rebuild, or re-render - effect; +- a control whose save effect cannot remain an explicit tab-owned handler; - button clusters, dynamic lists, Svelte components, rich diagnostic output, or destructive actions; and - styling which exists only to support the old wizard or tab menu. @@ -375,10 +419,11 @@ unrelated workflows: - configuration-level page visibility. It has no current `onSaved` handler, Svelte component, staged Apply group, or -destructive action. General is not the first proof because changing the display -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. +destructive action. General was not the first proof because changing the +display language re-renders the interface and other controls emit status events +after saving. After the standard binding was proven, these effects remained +explicit, tab-owned saved handlers while their one-key controls adopted +`SettingSpec`. The first native activation does not also divide other pages into searchable rows. It exposes their established pane renderers as custom pages, limited to @@ -438,8 +483,13 @@ smaller. 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. +Obsidian's definitions. The bounded improvement converts the General and +Logging controls and the simple Quick Setup actions, then organises Appearance, +Logging, and Extra menus as child pages of General Settings. It also removes +the now-misleading Setup child page and assigns its remaining responsibilities +to their existing owners: Extra menus, Maintenance, and Help and +troubleshooting. Further conversions remain 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 @@ -461,12 +511,21 @@ Stage B focused unit tests verify: - rendering the Advanced specifications through `LiveSyncSetting` preserves the current save behaviour. -Stage C1 focused unit tests verify: +Stage C1 and the landing-page focused unit tests verify: -- the page catalogue contains all 12 existing pages with stable, unique +- the page catalogue contains all 13 pane-based destinations with stable, unique identifiers and names; -- Advanced is the only native-items page, while the other 11 pages retain - custom factories; +- Appearance, Logging, Extra menus, and Advanced are native-items child pages, + and ten child pages retain custom factories; +- inactive and active configurations use their specified landing-page order; +- Remote Configuration and Sync Settings remain native navigable pages inside + the separate Synchronisation group; +- maintenance, extra features, advanced settings, and help have explicit page + groups, and the old Setup child page is absent; +- all eight General and Logging controls are registered once, with their + existing conditional visibility; +- the three Extra menus controls are registered once and refresh page + visibility after persistence; - every standard setting key is registered once; - reads use the editing buffer; - writes use `saveSettings([key])` and never `plugin.settings`; @@ -477,7 +536,11 @@ Stage C1 focused unit tests verify: Real-Obsidian verification on 1.13 or later confirms: -- native page navigation opens every page; +- the common landing controls and actions render before native page navigation; +- the Quick Setup action opens the maintained onboarding dialogue; +- Remote Configuration remains visible without initial scrolling in mobile + test mode; +- native page navigation opens every remaining child page; - Advanced controls appear in global settings search; - Advanced values persist and are restored after reopening settings; - CouchDB-dependent controls and Advanced-mode visibility update correctly; @@ -506,7 +569,7 @@ runtime and accessible native page names on 1.13 or later. Individual scenarios must not duplicate version checks or retain selectors for a menu which the declarative renderer does not create. -The accepted implementation was exercised against the official Obsidian +The initial Stage C1 implementation was exercised against the official Obsidian 1.13.4 arm64 AppImage with SHA-256 `20d0b13c6d40bb3d7e73d9b4be6d2e21dfcc145b2106a747d0c1b81e651dabfe`. That run opened all 12 pages from the native page catalogue, found the Advanced @@ -518,6 +581,18 @@ persistence of the same Advanced value. The shared E2E navigator owns both the separate settings renderer used by Obsidian 1.13 and the legacy `.sls-setting-menu-btn` interface. +The Stage C2 landing composition was then exercised on Obsidian 1.13.4. With +synchronisation inactive, the real interface rendered Quick Setup, a separate +Synchronisation group containing Remote Configuration and Sync Settings, and a +General Settings group containing Appearance, Logging, and Extra menus in the +specified order. It opened all 14 nested settings pages, found the Advanced +control through global settings search, and restored its saved value after +reopening settings. In mobile test mode, Remote Configuration remained inside +the initial viewport below the two Quick Setup actions and the Synchronisation +heading. The complete scenario also passed with the same bundle on Obsidian +1.12.7, confirming that the imperative fallback retained its navigation and +save behaviour. + ## Expansion Checkpoints Review the scope with the maintainer before any implementation adds one of the @@ -529,7 +604,7 @@ following: - a replacement for the current onboarding workflow; - a Commonlib setting metadata contract change; - a minimum Obsidian version increase; or -- conversion of Remote Configuration, Hatch, Maintenance, Setup, or the +- further conversion of Remote Configuration, Hatch, Maintenance, Help, or the Svelte-based Selector controls. These may become worthwhile after the first proof, but none is required to diff --git a/docs/settings.md b/docs/settings.md index 830c7e66..279b77b8 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,6 +4,19 @@ NOTE: This document not completed. I'll improve this doc in a while. but your co There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows: +On Obsidian 1.13 or later, the root settings page is organised by task. When synchronisation is inactive, **Quick Setup** appears first. Once any synchronisation mode is active, **Synchronisation** and **General Settings** move ahead of **Quick Setup**. **Set up other devices** appears after this plug-in has been configured. Earlier supported Obsidian versions retain a pane-based interface with the same controls. + +| Icon | Root group | Contents or availability | +| :--: | ------------------------ | ------------------------------------------------------------- | +| 🧙‍♂️ | Quick Setup | Setup URI, onboarding, and enable actions | +| 🔄 | Synchronisation | Remote Configuration and Sync Settings | +| ⚙️ | General Settings | Appearance, Logging, and Extra menus | +| 📲 | Set up other devices | Copy a Setup URI or show its QR code after configuration | +| 🛠️ | Maintenance and recovery | Maintenance and Hatch | +| 🧩 | Extra features | Selector and Customisation sync when advanced features appear | +| 🔧 | Advanced settings | Advanced, Power users, and Patches when their modes appear | +| ℹ️ | Help and information | Help and troubleshooting, and Change Log | + ## Feature maturity for 1.0 The following status applies to optional and compatibility features in the 1.0 line: @@ -18,7 +31,7 @@ The following status applies to optional and compatibility features in the 1.0 l | Icon | Description | | :--: | ------------------------------------------------------------------ | | 💬 | [0. Change Log](#0-change-log) | -| 🧙‍♂️ | [1. Setup](#1-setup) | +| 🧙‍♂️ | [1. Quick Setup and Extra menus](#1-quick-setup-and-extra-menus) | | ⚙️ | [2. General Settings](#2-general-settings) | | 🛰️ | [3. Remote Configuration](#3-remote-configuration) | | 🔄 | [4. Sync Settings](#4-sync-settings) | @@ -34,17 +47,19 @@ The following status applies to optional and compatibility features in the 1.0 l This pane always shows the current release history. It does not track whether a particular plug-in version has been read and does not open automatically after an ordinary update. -Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. Closing the dialogue keeps synchronisation paused. When the detected state can be handled by the running version, the explicit resume action records the current internal database version and restores the configured behaviour. A persistent Notice and the `Review why synchronisation is paused` command reopen the review. An older installation cannot dismiss a pause caused by a newer database or settings version. +Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. After the Obsidian layout is ready, a pending review opens as **Synchronisation paused for compatibility review**. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. Closing it or selecting **Keep synchronisation paused** leaves synchronisation paused. Use the persistent Notice's **Review why** link, or run the `Review why synchronisation is paused` command, to reopen it. Opening **Change Log** does not acknowledge the review. -## 1. Setup +A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. When the detected state can be handled by the running version, **Resume synchronisation** records the current internal database version and restores the configured behaviour. An older installation cannot dismiss a pause caused by a newer database or settings version. -This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync. +## 1. Quick Setup and Extra menus -An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, open **Self-hosted LiveSync settings** → **Setup** → **Rerun Onboarding Wizard**. +Quick Setup contains the actions used to configure Self-hosted LiveSync. On Obsidian 1.13 or later these actions appear on the root settings page. In the pane-based interface, they remain available together on the **Quick Setup** pane. + +An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, use **Rerun Onboarding Wizard** in the root **Quick Setup** group on Obsidian 1.13 or later. On earlier supported Obsidian versions, open **Self-hosted LiveSync settings** → **Quick Setup** → **Rerun Onboarding Wizard**. Choose the new-device path when this device owns the files which should initialise synchronisation. Choose the existing-device path when it should receive an established remote state. The wizard reserves Rebuild or Fetch respectively before enabling the settings and requesting a restart, so the selected initialisation runs before the ordinary start-up scan. -### 1. Quick Setup +### 1. Setup actions Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted LiveSync with a few clicks. @@ -64,22 +79,15 @@ Completing manual CouchDB, Object Storage, or P2P setup creates the correspondin This button only appears when the setup was not completed. If you have completed the setup manually, you can enable LiveSync on this device by this button. -### 2. To setup other devices +### 2. Set up other devices #### Copy the current settings to a Setup URI You can copy the current settings as a new setup URI. And this URI can be used to setup the other devices as [Use the copied setup URI](#use-the-copied-setup-uri). -### 3. Reset +### 3. Extra menus -#### Discard existing settings and databases - -Reset the Self-hosted LiveSync settings and databases. -**Hazardous operation. Please be careful when using this.** - -### 4. Enable extra and advanced features - -To keep the set-up dialogue simple, some panes are hidden in default. You can enable them here. +To keep the settings dialogue concise, some menus and features are hidden by default. On Obsidian 1.13 or later, enable them through **General Settings** → **Extra menus**. In the pane-based interface, the same controls appear in General Settings. #### Enable advanced features @@ -1085,4 +1093,8 @@ Use it only when the Vault, local database, and remote are healthy, and every re ### 7. Reset +#### Discard existing settings and databases + +Reset the Self-hosted LiveSync settings and local database. This is a hazardous operation; make a backup before using it. + #### Delete local database to reset or uninstall Self-hosted LiveSync diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a87c0e93..14d40344 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -47,6 +47,14 @@ Do not switch to P2P or reset the database as the first response. Check: If the remote is healthy but one device's local database is not, use [Reset Synchronisation on This Device](recovery.md#reset-synchronisation-on-this-device) only after backing up unsynchronised local files. +## Synchronisation is paused for compatibility review + +A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, or when a configured Vault is copied, restored, or opened in a new Obsidian profile without its device-local acknowledgement. + +The **Synchronisation paused for compatibility review** dialogue opens after the Obsidian layout is ready. If it has been closed, use the persistent Notice's **Review why** link, or run `Review why synchronisation is paused` from the command palette. Opening **Change Log** does not clear the pause. + +Review the stated reason before continuing. When **Resume synchronisation** is available, first update every synchronising device, then use that action to record the current internal database version and restore the configured synchronisation behaviour. If the action is unavailable, the running installation is older than the recorded database or settings format. Update that installation instead of resetting the database merely to remove the warning. + ## Files are missing or excluded Check Obsidian's `Detect all file extensions`, LiveSync selectors, ignore files, file-size limits, modification-time limits, and Hidden File Sync rules. A filtered file is different from a file which reached the database but could not be reconstructed from its chunks. diff --git a/src/common/messages/combinedMessages.prod.ts b/src/common/messages/combinedMessages.prod.ts index a28cdb36..abe85cb6 100644 --- a/src/common/messages/combinedMessages.prod.ts +++ b/src/common/messages/combinedMessages.prod.ts @@ -6364,6 +6364,9 @@ export const allMessages: Readonly { expect(translateLiveSyncMessage("Active Remote Type")).toBe(englishMessageTranslator("Active Remote Type")); }); + it("directs a compatibility pause to the dedicated review workflow", () => { + expect(translateLiveSyncMessage("Replicator.Message.VersionUpFlash")).toBe( + "Remote synchronisation is paused for compatibility review. Run the 'Review why synchronisation is paused' command for details and available actions." + ); + }); + it("uses LiveSync-owned provisional English without extending Commonlib's message contract", () => { expect($msg("This file has unresolved conflicts.")).toBe("This file has unresolved conflicts."); expect($msg("More actions for ${DEVICE}", { DEVICE: "phone" })).toBe("More actions for phone"); diff --git a/src/modules/features/SettingDialogue/GeneralSettingSpecs.ts b/src/modules/features/SettingDialogue/GeneralSettingSpecs.ts new file mode 100644 index 00000000..a839a2e1 --- /dev/null +++ b/src/modules/features/SettingDialogue/GeneralSettingSpecs.ts @@ -0,0 +1,75 @@ +import { $msg, $t } from "@/common/translation"; +import { SUPPORTED_I18N_LANGS } from "@/common/rosetta"; +import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import type { SettingSpecGroup } from "./SettingSpec.ts"; + +export type GeneralSettingSpecContext = { + showEditorStatusDetails: () => boolean; + showVerboseLog: () => boolean; +}; + +/** Build the shared Appearance and Logging controls. */ +export function createGeneralSettingSpecGroups({ + showEditorStatusDetails, + showVerboseLog, +}: GeneralSettingSpecContext): readonly SettingSpecGroup[] { + return [ + { + heading: $msg("obsidianLiveSyncSettingTab.titleAppearance"), + items: [ + { + key: "displayLanguage", + control: { + type: "dropdown", + options: () => + Object.fromEntries( + SUPPORTED_I18N_LANGS.map((language) => [language, $t(`lang-${language}`)]) + ), + }, + }, + { key: "showStatusOnEditor", control: { type: "toggle" } }, + { + key: "showOnlyIconsOnEditor", + control: { type: "toggle" }, + visible: showEditorStatusDetails, + }, + { key: "showStatusOnStatusbar", control: { type: "toggle" } }, + { key: "hideFileWarningNotice", control: { type: "toggle" } }, + { + key: "networkWarningStyle", + control: { + type: "dropdown", + options: () => ({ + [NetworkWarningStyles.BANNER]: "Show full banner", + [NetworkWarningStyles.ICON]: "Show icon only", + [NetworkWarningStyles.HIDDEN]: "Hide completely", + }), + }, + }, + ], + }, + { + heading: $msg("obsidianLiveSyncSettingTab.titleLogging"), + items: [ + { key: "lessInformationInLog", control: { type: "toggle" } }, + { + key: "showVerboseLog", + control: { type: "toggle" }, + visible: showVerboseLog, + }, + ], + }, + ]; +} + +/** Build the feature-level controls shown in General Settings under Extra menus. */ +export function createExtraMenuSettingSpecGroup(): SettingSpecGroup { + return { + heading: $msg("obsidianLiveSyncSettingTab.titleExtraMenus"), + items: [ + { key: "useAdvancedMode", control: { type: "toggle" } }, + { key: "usePowerUserMode", control: { type: "toggle" } }, + { key: "useEdgeCaseMode", control: { type: "toggle" } }, + ], + }; +} diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts index 5473c464..dade80ef 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts @@ -1,6 +1,6 @@ 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 { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian"; import type { PageFunctions } from "./SettingPane.ts"; const runtime = vi.hoisted(() => ({ @@ -9,7 +9,7 @@ const runtime = vi.hoisted(() => ({ unload: ReturnType; callbacks: Array<() => unknown>; }>, - paneGeneral: vi.fn(), + paneChangeLog: vi.fn(), pageCleanup: vi.fn(), savedEffect: vi.fn(), superHide: vi.fn(), @@ -73,9 +73,14 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({ }, })); vi.mock("@/common/events.ts", () => ({ + EVENT_ON_UNRESOLVED_ERROR: "on-unresolved-error", + EVENT_REQUEST_COPY_SETUP_URI: "request-copy-setup-uri", + EVENT_REQUEST_OPEN_SETUP_URI: "request-open-setup-uri", EVENT_REQUEST_RELOAD_SETTING_TAB: "request-reload-setting-tab", - eventHub: { onEvent: vi.fn() }, + EVENT_REQUEST_SHOW_SETUP_QR: "request-show-setup-qr", + eventHub: { emitEvent: vi.fn(), onEvent: vi.fn() }, })); +vi.mock("@/modules/features/SetupManager.ts", () => ({ SetupManager: class {} })); vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({ checkSyncInfo: vi.fn() })); vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ LiveSyncCouchDBReplicator: class {}, @@ -91,9 +96,10 @@ vi.mock("./SettingPane.ts", () => ({ 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("./PaneChangeLog.ts", () => ({ paneChangeLog: runtime.paneChangeLog })); +vi.mock("./PaneQuickSetup.ts", () => ({ paneQuickSetup: vi.fn() })); +vi.mock("./PaneHelp.ts", () => ({ paneHelp: 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() })); @@ -111,6 +117,30 @@ function isPage(item: SettingDefinitionItem): item is SettingDefinitionPage { return "type" in item && item.type === "page"; } +function isGroup(item: SettingDefinitionItem): item is SettingDefinitionGroup { + return "type" in item && item.type === "group"; +} + +function itemLabel(item: SettingDefinitionItem): string { + if (isPage(item)) return item.name; + if (isGroup(item)) return item.heading ?? ""; + return item.name; +} + +function collectPages(items: readonly SettingDefinitionItem[]): SettingDefinitionPage[] { + return items.flatMap((item) => { + if (isPage(item)) return [item, ...collectPages(item.items ?? [])]; + if (isGroup(item)) return collectPages(item.items ?? []); + return []; + }); +} + +function findPage(tab: ObsidianLiveSyncSettingTab, name: string): SettingDefinitionPage { + const page = collectPages(tab.getSettingDefinitions()).find((candidate) => candidate.name.endsWith(` ${name}`)); + if (!page) throw new Error(`${name} custom page is unavailable`); + return page; +} + function createSettingsTab(): ObsidianLiveSyncSettingTab { const plugin = { app: {}, @@ -137,8 +167,8 @@ function createSettingsTab(): ObsidianLiveSyncSettingTab { beforeEach(() => { runtime.components.length = 0; - runtime.paneGeneral.mockClear(); - runtime.paneGeneral.mockImplementation(function (this: ObsidianLiveSyncSettingTab) { + runtime.paneChangeLog.mockClear(); + runtime.paneChangeLog.mockImplementation(function (this: ObsidianLiveSyncSettingTab) { this.lifetimeComponent.register(runtime.pageCleanup); }); runtime.pageCleanup.mockClear(); @@ -147,36 +177,175 @@ beforeEach(() => { }); describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { - it("returns all catalogue pages and keeps Advanced as native items", () => { + it("keeps Quick Setup first while synchronisation is inactive and separates synchronisation pages from it", () => { const tab = createSettingsTab(); - const pages = tab.getSettingDefinitions().filter(isPage); + const definitions = tab.getSettingDefinitions(); - expect(pages).toHaveLength(12); - expect(pages.map(({ name }) => name)).toEqual( - createSettingsPageCatalogue().map((entry) => `${entry.icon} ${entry.name()}`) + expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ + "🧙‍♂️ Quick Setup", + "🔄 Synchronisation", + "⚙️ General Settings", + ]); + }); + + it("keeps the synchronisation group first and orders General Settings before Quick Setup while synchronisation is active", () => { + const tab = createSettingsTab(); + tab.editingSettings.liveSync = true; + const definitions = tab.getSettingDefinitions(); + + expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ + "🔄 Synchronisation", + "⚙️ General Settings", + "🧙‍♂️ Quick Setup", + ]); + }); + + it("keeps Remote Configuration and Sync Settings as native pages inside the Synchronisation group", () => { + const tab = createSettingsTab(); + const definitions = tab.getSettingDefinitions(); + const synchronisation = definitions.find( + (item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "🔄 Synchronisation" ); + + expect(synchronisation?.items?.filter(isPage).map(({ name }) => name)).toEqual([ + "🛰️ Remote Configuration", + "🔄 Sync Settings", + ]); + expect( + definitions + .filter(isPage) + .map(({ name }) => name) + .filter((name) => name.endsWith(" Remote Configuration") || name.endsWith(" Sync Settings")) + ).toEqual([]); + }); + + it("groups secondary pages by purpose instead of exposing a flat Detailed settings list", () => { + const tab = createSettingsTab(); + const definitions = tab.getSettingDefinitions(); + const groups = definitions.filter(isGroup); + + expect(groups.map(({ heading }) => heading)).toEqual([ + "🧙‍♂️ Quick Setup", + "🔄 Synchronisation", + "⚙️ General Settings", + "📲 Set up other devices", + "🛠️ Maintenance and recovery", + "🧩 Extra features", + "🔧 Advanced settings", + "ℹ️ Help and information", + ]); + expect( + groups + .find(({ heading }) => heading === "🛠️ Maintenance and recovery") + ?.items?.filter(isPage) + .map(({ name }) => name) + ).toEqual(["🎛️ Maintenance", "🧰 Hatch"]); + expect( + groups + .find(({ heading }) => heading === "🧩 Extra features") + ?.items?.filter(isPage) + .map(({ name }) => name) + ).toEqual(["🚦 Selector", "🔌 Customisation sync"]); + expect( + groups + .find(({ heading }) => heading === "🔧 Advanced settings") + ?.items?.filter(isPage) + .map(({ name }) => name) + ).toEqual(["🔧 Advanced", "💪 Power users", "🩹 Patches"]); + expect( + groups + .find(({ heading }) => heading === "ℹ️ Help and information") + ?.items?.filter(isPage) + .map(({ name }) => name) + ).toEqual(["❓ Help and troubleshooting", "💬 Change Log"]); + expect( + groups.find(({ heading }) => heading === "📲 Set up other devices")?.items?.map(({ name }) => name) + ).toEqual(["Copy the current settings to a Setup URI", "Show QR code"]); + }); + + it("keeps Appearance, Logging, and Extra menus inside General Settings", () => { + const tab = createSettingsTab(); + const definitions = tab.getSettingDefinitions(); + const general = definitions.find( + (item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "⚙️ General Settings" + ); + const generalPages = general?.items?.filter(isPage); + const appearance = generalPages?.find(({ name }) => name === "🎨 Appearance"); + const logging = generalPages?.find(({ name }) => name === "📝 Logging"); + const extraMenus = general?.items?.find( + (item): item is SettingDefinitionPage => isPage(item) && item.name === "🎚️ Extra menus" + ); + + expect(generalPages?.map(({ name }) => name)).toEqual(["🎨 Appearance", "📝 Logging", "🎚️ Extra menus"]); + expect( + appearance?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : [])) + ).toEqual([ + "displayLanguage", + "showStatusOnEditor", + "showOnlyIconsOnEditor", + "showStatusOnStatusbar", + "hideFileWarningNotice", + "networkWarningStyle", + ]); + expect( + logging?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : [])) + ).toEqual(["lessInformationInLog", "showVerboseLog"]); + expect( + extraMenus?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : [])) + ).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]); + }); + + it("omits the old Setup child page and keeps standard General and Advanced pages native", () => { + const tab = createSettingsTab(); + const pages = collectPages(tab.getSettingDefinitions()); + + expect(pages).toHaveLength(14); + expect(pages.map(({ name }) => name)).toEqual( + expect.arrayContaining( + createSettingsPageCatalogue() + .filter(({ id }) => id !== "general" && id !== "quick-setup") + .map((entry) => `${entry.icon} ${entry.name()}`) + ) + ); + expect(pages.some(({ name }) => name.endsWith(" General Settings"))).toBe(false); + expect(pages.some(({ name }) => name.endsWith(" Setup"))).toBe(false); const advanced = pages.find(({ name }) => name.endsWith(" 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); + expect(pages.filter(({ page }) => page !== undefined)).toHaveLength(10); + }); + + it("keeps simple setup actions on the landing page without a second Setup destination", () => { + const tab = createSettingsTab(); + const definitions = tab.getSettingDefinitions(); + const quickSetup = definitions.find( + (item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "🧙‍♂️ Quick Setup" + ); + + expect(quickSetup?.items?.map(({ name }) => name)).toEqual([ + "Connect with Setup URI", + "Rerun Onboarding Wizard", + "Enable LiveSync", + ]); + expect(collectPages(definitions).some(({ name }) => name.endsWith(" Setup"))).toBe(false); }); 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"); + const changeLog = findPage(tab, "Change Log"); + if (!changeLog.page) { + throw new Error("Change Log custom page is unavailable"); } expect(runtime.components).toHaveLength(0); - const page = general.page(); + const page = changeLog.page(); expect(runtime.components).toHaveLength(0); page.display(); - expect(runtime.paneGeneral).toHaveBeenCalledOnce(); + expect(runtime.paneChangeLog).toHaveBeenCalledOnce(); expect(runtime.components).toHaveLength(1); expect(runtime.components[0].load).toHaveBeenCalledOnce(); @@ -192,7 +361,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { }); it("does not run delayed pane work after its page scope has been disposed", async () => { - runtime.paneGeneral.mockImplementation(function ( + runtime.paneChangeLog.mockImplementation(function ( this: ObsidianLiveSyncSettingTab, _paneEl: HTMLElement, { addPanel }: Pick @@ -202,12 +371,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { }); }); const tab = createSettingsTab(); - const general = tab.getSettingDefinitions().filter(isPage)[2]; - if (!general?.page) { - throw new Error("General custom page is unavailable"); + const changeLog = findPage(tab, "Change Log"); + if (!changeLog.page) { + throw new Error("Change Log custom page is unavailable"); } - const page = general.page(); + const page = changeLog.page(); page.display(); page.hide(); await Promise.resolve(); @@ -216,7 +385,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { }); it("runs a delayed pane callback inside its active scope before a queued hide", async () => { - runtime.paneGeneral.mockImplementation(function ( + runtime.paneChangeLog.mockImplementation(function ( this: ObsidianLiveSyncSettingTab, _paneEl: HTMLElement, { addPanel }: Pick @@ -226,12 +395,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { }); }); const tab = createSettingsTab(); - const general = tab.getSettingDefinitions().filter(isPage)[2]; - if (!general?.page) { - throw new Error("General custom page is unavailable"); + const changeLog = findPage(tab, "Change Log"); + if (!changeLog.page) { + throw new Error("Change Log custom page is unavailable"); } - const page = general.page(); + const page = changeLog.page(); page.display(); queueMicrotask(() => page.hide()); await Promise.resolve(); @@ -242,11 +411,11 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { 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"); + const changeLog = findPage(tab, "Change Log"); + if (!changeLog.page) { + throw new Error("Change Log custom page is unavailable"); } - general.page().display(); + changeLog.page().display(); tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode; tab.requestReload(); @@ -254,13 +423,22 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { expect(tab.update).toHaveBeenCalledOnce(); }); + it("rebuilds the catalogue after an Extra menus feature level is saved", async () => { + const tab = createSettingsTab(); + tab.editingSettings.usePowerUserMode = true; + + await tab.saveSettings(["usePowerUserMode"]); + + 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"); + const changeLog = findPage(tab, "Change Log"); + if (!changeLog.page) { + throw new Error("Change Log custom page is unavailable"); } - general.page().display(); + changeLog.page().display(); tab.core.settings.displayLanguage = "ja"; tab.requestReload(); @@ -270,11 +448,11 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { 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"); + const changeLog = findPage(tab, "Change Log"); + if (!changeLog.page) { + throw new Error("Change Log custom page is unavailable"); } - general.page().display(); + changeLog.page().display(); tab.initialSettings!.usePowerUserMode = false; tab.editingSettings.usePowerUserMode = true; tab.core.settings.usePowerUserMode = true; @@ -298,30 +476,30 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { 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 changeLog = findPage(tab, "Change Log"); + if (!changeLog.page) { + throw new Error("Change Log custom page is unavailable"); } - const page = general.page(); + const page = changeLog.page(); page.display(); vi.mocked(tab.update).mockImplementation(() => page.hide()); tab.requestCatalogueRefresh(); - expect(runtime.paneGeneral).toHaveBeenCalledOnce(); + expect(runtime.paneChangeLog).toHaveBeenCalledOnce(); }); it("keeps saved-setting effects owned by the tab after a custom page closes", async () => { - runtime.paneGeneral.mockImplementation(function (this: ObsidianLiveSyncSettingTab) { + runtime.paneChangeLog.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 changeLog = findPage(tab, "Change Log"); + if (!changeLog.page) { + throw new Error("Change Log custom page is unavailable"); } - const page = general.page(); + const page = changeLog.page(); page.display(); page.hide(); tab.editingSettings.displayLanguage = "ja"; diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index e401545c..7acf83bb 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -32,7 +32,14 @@ import { 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 { + EVENT_ON_UNRESOLVED_ERROR, + EVENT_REQUEST_COPY_SETUP_URI, + EVENT_REQUEST_OPEN_SETUP_URI, + EVENT_REQUEST_RELOAD_SETTING_TAB, + EVENT_REQUEST_SHOW_SETUP_QR, + eventHub, +} from "@/common/events.ts"; import { enableOnly, // findAttrFromParent, @@ -54,12 +61,21 @@ import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replicati import { closeObsidianSettings } from "@/common/obsidianSettings.ts"; import { createAdvancedSettingDefinitionGroups, + createExtraMenuSettingDefinitions, + createGeneralSettingDefinitionGroups, 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"; +import type { + SettingDefinitionAction, + SettingDefinitionGroup, + SettingDefinitionItem, + SettingDefinitionPage, +} from "obsidian"; +import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts"; +import { SetupManager } from "@/modules/features/SetupManager.ts"; // For creating a document // const toc = new Set(); @@ -312,6 +328,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => { this.requestReload(); }); + this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh()); + this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR)); + this.addOnSaved("networkWarningStyle", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR)); + this.addOnSaved("useAdvancedMode", () => this.requestCatalogueRefresh()); + this.addOnSaved("usePowerUserMode", () => this.requestCatalogueRefresh()); + this.addOnSaved("useEdgeCaseMode", () => this.requestCatalogueRefresh()); } async testConnection(settingOverride: Partial = {}): Promise { @@ -339,6 +361,29 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { closeObsidianSettings(this.plugin.app); } + requestOpenSetupURI(): void { + this.closeSetting(); + eventHub.emitEvent(EVENT_REQUEST_OPEN_SETUP_URI); + } + + async rerunOnboardingWizard(): Promise { + await this.core.getModule(SetupManager).startOnBoarding(); + } + + async enableLiveSyncFromSettings(): Promise { + this.editingSettings.isConfigured = true; + await this.saveAllDirtySettings(); + this.services.appLifecycle.askRestart(); + } + + requestCopySetupURI(): void { + eventHub.emitEvent(EVENT_REQUEST_COPY_SETUP_URI); + } + + requestShowSetupQRCode(): void { + eventHub.emitEvent(EVENT_REQUEST_SHOW_SETUP_QR); + } + handleElement(element: HTMLElement, func: OnUpdateFunc) { const updateFunc = ((element, func) => { const prev = {} as OnUpdateResult; @@ -409,7 +454,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { key === "displayLanguage" || key === "useAdvancedMode" || key === "usePowerUserMode" || - key === "useEdgeCaseMode" + key === "useEdgeCaseMode" || + key === "isConfigured" || + key === "liveSync" || + key === "periodicReplication" || + key === "syncOnSave" || + key === "syncOnEditorSave" || + key === "syncOnStart" || + key === "syncOnFileOpen" || + key === "syncAfterMerge" ); } @@ -564,9 +617,16 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { } private getDeclarativeSettingSpec(key: string): SettingSpec { - const spec = createAdvancedSettingSpecGroups({ - isCouchDB: () => this.isConfiguredAs("remoteType", REMOTE_COUCHDB), - }) + const spec = [ + ...createGeneralSettingSpecGroups({ + showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true), + showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false), + }), + createExtraMenuSettingSpecGroup(), + ...createAdvancedSettingSpecGroups({ + isCouchDB: () => this.isConfiguredAs("remoteType", REMOTE_COUCHDB), + }), + ] .flatMap((group) => group.items) .find((candidate) => candidate.key === key); if (!spec) { @@ -697,28 +757,165 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { throw new Error("Custom settings pages require Obsidian 1.13.0 or later"); } + private createDeclarativePage(entry: SettingsPageEntry): SettingDefinitionPage { + const page: SettingDefinitionPage = { + type: "page", + name: `${entry.icon} ${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 createGeneralSettingsGroup(): SettingDefinitionGroup { + const groups = createGeneralSettingDefinitionGroups({ + showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true), + showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false), + }); + const [appearance, logging] = groups; + if (!appearance || !logging) { + throw new Error("General settings must define Appearance and Logging groups"); + } + return this.createPageGroup(`⚙️ ${$msg("obsidianLiveSyncSettingTab.panelGeneralSettings")}`, [ + { + type: "page", + name: `🎨 ${appearance.heading}`, + items: appearance.items, + }, + { + type: "page", + name: `📝 ${logging.heading}`, + items: logging.items, + }, + this.createExtraMenusPage(), + ]); + } + + private createExtraMenusPage(): SettingDefinitionPage { + return { + type: "page", + name: `🎚️ ${$msg("obsidianLiveSyncSettingTab.titleExtraMenus")}`, + items: createExtraMenuSettingDefinitions(), + }; + } + + private createQuickSetupGroup(): SettingDefinitionGroup { + return { + type: "group", + heading: `🧙‍♂️ ${$msg("obsidianLiveSyncSettingTab.titleQuickSetup")}`, + items: [ + { + name: $msg("obsidianLiveSyncSettingTab.nameConnectSetupURI"), + desc: $msg("obsidianLiveSyncSettingTab.descConnectSetupURI"), + action: () => this.requestOpenSetupURI(), + }, + { + name: $msg("Rerun Onboarding Wizard"), + desc: $msg("Rerun the onboarding wizard to set up Self-hosted LiveSync again."), + action: () => fireAndForget(async () => await this.rerunOnboardingWizard()), + }, + { + name: $msg("obsidianLiveSyncSettingTab.nameEnableLiveSync"), + desc: $msg("obsidianLiveSyncSettingTab.descEnableLiveSync"), + visible: () => !this.isConfiguredAs("isConfigured", true), + action: () => fireAndForget(async () => await this.enableLiveSyncFromSettings()), + }, + ], + }; + } + + private createSynchronisationGroup(pages: SettingDefinitionPage[]): SettingDefinitionGroup { + return this.createPageGroup(`🔄 ${$msg("obsidianLiveSyncSettingTab.titleSynchronisation")}`, pages); + } + + private createPageGroup( + heading: string, + pages: SettingDefinitionPage[], + visible?: () => boolean + ): SettingDefinitionGroup { + return { + type: "group", + heading, + items: pages, + ...(visible ? { visible } : {}), + }; + } + + private createSetupOtherDevicesGroup(): SettingDefinitionGroup { + return { + type: "group", + heading: `📲 ${$msg("obsidianLiveSyncSettingTab.titleSetupOtherDevices")}`, + visible: () => this.isConfiguredAs("isConfigured", true), + items: [ + { + name: $msg("obsidianLiveSyncSettingTab.nameCopySetupURI"), + desc: $msg("obsidianLiveSyncSettingTab.descCopySetupURI"), + action: () => this.requestCopySetupURI(), + }, + { + name: $msg("Setup.ShowQRCode"), + desc: $msg("Setup.ShowQRCode.Desc"), + action: () => this.requestShowSetupQRCode(), + }, + ], + }; + } + override getSettingDefinitions(): SettingDefinitionItem[] { if (!this.supportsDeclarativeSettings()) { return []; } - return createSettingsPageCatalogue().map((entry): SettingDefinitionPage => { - const page: SettingDefinitionPage = { - type: "page", - name: `${entry.icon} ${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); + const catalogue = createSettingsPageCatalogue(); + const getPage = (id: string): SettingDefinitionPage => { + const entry = catalogue.find((candidate) => candidate.id === id); + if (!entry) { + throw new Error(`Unknown settings page: ${id}`); } - return page; - }); + return this.createDeclarativePage(entry); + }; + const synchronisation = this.createSynchronisationGroup([ + getPage("remote-configuration"), + getPage("synchronisation"), + ]); + const generalSettings = this.createGeneralSettingsGroup(); + const quickSetup = this.createQuickSetupGroup(); + const setupOtherDevices = this.createSetupOtherDevicesGroup(); + const maintenance = this.createPageGroup( + `🛠️ ${$msg("obsidianLiveSyncSettingTab.titleMaintenanceAndRecovery")}`, + [getPage("maintenance"), getPage("hatch")] + ); + const extraFeatures = this.createPageGroup( + `🧩 ${$msg("obsidianLiveSyncSettingTab.titleExtraFeaturesGroup")}`, + [getPage("selector"), getPage("customisation-sync")], + () => this.isPageVisible(LEVEL_ADVANCED) + ); + const advancedSettings = this.createPageGroup( + `🔧 ${$msg("obsidianLiveSyncSettingTab.titleAdvancedSettings")}`, + [getPage("advanced"), getPage("power-users"), getPage("patches")], + () => + this.isPageVisible(LEVEL_ADVANCED) || + this.isPageVisible(LEVEL_POWER_USER) || + this.isPageVisible(LEVEL_EDGE_CASE) + ); + const helpAndInformation = this.createPageGroup( + `ℹ️ ${$msg("obsidianLiveSyncSettingTab.titleHelpAndInformation")}`, + [getPage("help"), getPage("change-log")] + ); + const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation]; + + if (this.isAnySyncEnabled()) { + return [synchronisation, generalSettings, quickSetup, ...laterGroups]; + } + return [quickSetup, synchronisation, generalSettings, ...laterGroups]; } private beginRenderScope(refresh: () => void): Component { diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts index 6f99658a..9234e996 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.unit.spec.ts @@ -27,9 +27,14 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({ }, })); vi.mock("@/common/events.ts", () => ({ + EVENT_ON_UNRESOLVED_ERROR: "on-unresolved-error", + EVENT_REQUEST_COPY_SETUP_URI: "request-copy-setup-uri", + EVENT_REQUEST_OPEN_SETUP_URI: "request-open-setup-uri", EVENT_REQUEST_RELOAD_SETTING_TAB: "request-reload-setting-tab", - eventHub: { onEvent: vi.fn() }, + EVENT_REQUEST_SHOW_SETUP_QR: "request-show-setup-qr", + eventHub: { emitEvent: vi.fn(), onEvent: vi.fn() }, })); +vi.mock("@/modules/features/SetupManager.ts", () => ({ SetupManager: class {} })); vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => negotiationMocks); vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ LiveSyncCouchDBReplicator: class {}, @@ -42,7 +47,8 @@ vi.mock("./SettingPane.ts", () => ({ visibleOnly: vi.fn(() => vi.fn()), })); vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() })); -vi.mock("./PaneSetup.ts", () => ({ paneSetup: vi.fn() })); +vi.mock("./PaneQuickSetup.ts", () => ({ paneQuickSetup: vi.fn() })); +vi.mock("./PaneHelp.ts", () => ({ paneHelp: vi.fn() })); vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: vi.fn() })); vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() })); vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() })); diff --git a/src/modules/features/SettingDialogue/PaneGeneral.ts b/src/modules/features/SettingDialogue/PaneGeneral.ts index 6d3ab1c0..fc595312 100644 --- a/src/modules/features/SettingDialogue/PaneGeneral.ts +++ b/src/modules/features/SettingDialogue/PaneGeneral.ts @@ -1,46 +1,22 @@ -import { $msg, $t } from "@/common/translation"; -import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@/common/rosetta"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { PageFunctions } from "./SettingPane.ts"; -import { visibleOnly } from "./SettingPane.ts"; -import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts"; -import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; -export function paneGeneral(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void { - void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleAppearance")).then((paneEl) => { - const languages = Object.fromEntries([ - // ["", $msg("obsidianLiveSyncSettingTab.defaultLanguage")], - ...SUPPORTED_I18N_LANGS.map((e) => [e, $t(`lang-${e}`)]), - ]) as Record; - new Setting(paneEl).autoWireDropDown("displayLanguage", { - options: languages, - }); - this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh()); - new Setting(paneEl).autoWireToggle("showStatusOnEditor"); - this.addOnSaved("showStatusOnEditor", () => { - eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); - }); - new Setting(paneEl).autoWireToggle("showOnlyIconsOnEditor", { - onUpdate: visibleOnly(() => this.isConfiguredAs("showStatusOnEditor", true)), - }); - new Setting(paneEl).autoWireToggle("showStatusOnStatusbar"); - new Setting(paneEl).autoWireToggle("hideFileWarningNotice"); - new Setting(paneEl).autoWireDropDown("networkWarningStyle", { - options: { - [NetworkWarningStyles.BANNER]: "Show full banner", - [NetworkWarningStyles.ICON]: "Show icon only", - [NetworkWarningStyles.HIDDEN]: "Hide completely", - }, - }); - this.addOnSaved("networkWarningStyle", () => { - eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR); - }); - }); - void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleLogging")).then((paneEl) => { - new Setting(paneEl).autoWireToggle("lessInformationInLog"); +import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts"; +import { renderLegacySettingSpec } from "./SettingSpec.ts"; - new Setting(paneEl).autoWireToggle("showVerboseLog", { - onUpdate: visibleOnly(() => this.isConfiguredAs("lessInformationInLog", false)), +export function paneGeneral(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void { + const groups = [ + ...createGeneralSettingSpecGroups({ + showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true), + showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false), + }), + createExtraMenuSettingSpecGroup(), + ]; + for (const group of groups) { + void addPanel(paneEl, group.heading).then((panelEl) => { + for (const spec of group.items) { + renderLegacySettingSpec(new Setting(panelEl), spec); + } }); - }); + } } diff --git a/src/modules/features/SettingDialogue/PaneHelp.ts b/src/modules/features/SettingDialogue/PaneHelp.ts new file mode 100644 index 00000000..c0923752 --- /dev/null +++ b/src/modules/features/SettingDialogue/PaneHelp.ts @@ -0,0 +1,102 @@ +import { MarkdownRenderer, request } from "@/deps.ts"; +import { $msg } from "@/common/translation"; +import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"; +import { fireAndForget } from "octagonal-wheels/promises"; +import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; +import type { PageFunctions } from "./SettingPane.ts"; + +/** Render the online help and troubleshooting browser. */ +export function paneHelp(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void { + void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleOnlineTips")).then((panelEl) => { + const lifetimeComponent = this.lifetimeComponent; + let pageDisposed = false; + lifetimeComponent.register(() => { + pageDisposed = true; + }); + const repo = "vrtmrz/obsidian-livesync"; + const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting"); + const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`; + this.createEl(panelEl, "div", "", (el) => { + el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => { + anchor.href = `https://github.com/${repo}/blob/main${topPath}`; + anchor.target = "_blank"; + anchor.rel = "noopener"; + }); + }); + const troubleShootEl = this.createEl(panelEl, "div", { + text: "", + cls: "sls-troubleshoot-preview", + }); + const loadMarkdownPage = async (pathAll: string, basePathParam: string = "") => { + troubleShootEl.setCssStyles({ minHeight: troubleShootEl.clientHeight + "px" }); + troubleShootEl.empty(); + const fullPath = pathAll.startsWith("/") ? pathAll : `${basePathParam}/${pathAll}`; + + const directoryArr = fullPath.split("/"); + const filename = directoryArr.pop(); + const basePath = directoryArr.join("/"); + + let remoteTroubleShootMDSrc = ""; + try { + remoteTroubleShootMDSrc = await request(`${rawRepoURI}${basePath}/${filename}`); + } catch (ex) { + 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)` + ); + await MarkdownRenderer.render( + this.plugin.app, + ` [${$msg("obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting")}](${topPath}) [${$msg("obsidianLiveSyncSettingTab.linkPageTop")}](${filename})\n\n${remoteTroubleShootMD}`, + troubleShootEl, + `${rawRepoURI}`, + lifetimeComponent + ); + if (pageDisposed) return; + troubleShootEl.querySelector(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({ + position: "sticky", + top: "-1em", + backgroundColor: "var(--modal-background)", + }); + troubleShootEl.querySelectorAll("a.internal-link").forEach((anchorEl) => { + anchorEl.addEventListener("click", (evt) => { + fireAndForget(async () => { + const uri = anchorEl.getAttr("data-href"); + if (!uri) return; + if (uri.startsWith("#")) { + evt.preventDefault(); + const elements = Array.from( + troubleShootEl.querySelectorAll("[data-heading]") + ); + const target = elements.find( + (element) => + element.getAttr("data-heading")?.toLowerCase().split(" ").join("-") === + uri.substring(1).toLowerCase() + ); + if (target) { + target.setCssStyles({ scrollMargin: "3em" }); + target.scrollIntoView({ + behavior: "instant", + block: "start", + }); + } + } else { + evt.preventDefault(); + await loadMarkdownPage(uri, basePath); + troubleShootEl.setCssStyles({ scrollMargin: "1em" }); + troubleShootEl.scrollIntoView({ + behavior: "instant", + block: "start", + }); + } + }); + }); + }); + troubleShootEl.setCssStyles({ minHeight: "" }); + }; + void loadMarkdownPage(topPath); + }); +} diff --git a/src/modules/features/SettingDialogue/PaneMaintenance.ts b/src/modules/features/SettingDialogue/PaneMaintenance.ts index 36d9ccaa..d79a2b8c 100644 --- a/src/modules/features/SettingDialogue/PaneMaintenance.ts +++ b/src/modules/features/SettingDialogue/PaneMaintenance.ts @@ -1,4 +1,9 @@ import { EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events.ts"; +import { $msg } from "@/common/translation"; +import { + createCoreSettingsAfterFullReset, + createEditingSettingsAfterFullReset, +} from "@/serviceFeatures/setupObsidian/settingsReset.ts"; import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { FlagFilesHumanReadable, FlagFilesOriginal } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; @@ -369,6 +374,30 @@ export function paneMaintenance( }); void addPanel(paneEl, "Reset").then((paneEl) => { + new Setting(paneEl) + .setName($msg("obsidianLiveSyncSettingTab.nameDiscardSettings")) + .addButton((button) => { + setButtonDestructiveState(button) + .setButtonText($msg("obsidianLiveSyncSettingTab.btnDiscard")) + .onClick(async () => { + if ( + (await this.core.confirm.askYesNoDialog( + $msg("obsidianLiveSyncSettingTab.msgDiscardConfirmation"), + { defaultOption: "No" } + )) !== "yes" + ) { + return; + } + this.editingSettings = createEditingSettingsAfterFullReset(this.editingSettings); + await this.saveAllDirtySettings(); + this.core.settings = createCoreSettingsAfterFullReset(); + await this.services.setting.saveSettingData(); + await this.services.database.resetDatabase(); + this.services.appLifecycle.askRestart(); + }); + }) + .addOnUpdate(visibleOnly(() => this.isConfiguredAs("isConfigured", true))); + new Setting(paneEl) .setName("Delete local database to reset or uninstall Self-hosted LiveSync") .addButton((button) => diff --git a/src/modules/features/SettingDialogue/PaneQuickSetup.ts b/src/modules/features/SettingDialogue/PaneQuickSetup.ts new file mode 100644 index 00000000..72b1fb61 --- /dev/null +++ b/src/modules/features/SettingDialogue/PaneQuickSetup.ts @@ -0,0 +1,65 @@ +import { $msg } from "@/common/translation"; +import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; +import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; +import { visibleOnly, type PageFunctions } from "./SettingPane.ts"; + +/** Render setup actions in the pane-based settings interface used before Obsidian 1.13. */ +export function paneQuickSetup( + this: ObsidianLiveSyncSettingTab, + paneEl: HTMLElement, + { addPanel }: PageFunctions +): void { + void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleQuickSetup")).then((panelEl) => { + new Setting(panelEl) + .setName($msg("obsidianLiveSyncSettingTab.nameConnectSetupURI")) + .setDesc($msg("obsidianLiveSyncSettingTab.descConnectSetupURI")) + .addButton((button) => { + button.setButtonText($msg("obsidianLiveSyncSettingTab.btnUse")).onClick(() => { + this.requestOpenSetupURI(); + }); + }); + + new Setting(panelEl) + .setName($msg("Rerun Onboarding Wizard")) + .setDesc($msg("Rerun the onboarding wizard to set up Self-hosted LiveSync again.")) + .addButton((button) => { + button.setButtonText($msg("Rerun Wizard")).onClick(async () => { + await this.rerunOnboardingWizard(); + }); + }); + + new Setting(panelEl) + .setName($msg("obsidianLiveSyncSettingTab.nameEnableLiveSync")) + .setDesc($msg("obsidianLiveSyncSettingTab.descEnableLiveSync")) + .addOnUpdate(visibleOnly(() => !this.isConfiguredAs("isConfigured", true))) + .addButton((button) => { + button.setButtonText($msg("obsidianLiveSyncSettingTab.btnEnable")).onClick(async () => { + await this.enableLiveSyncFromSettings(); + }); + }); + }); + + void addPanel( + paneEl, + `📲 ${$msg("obsidianLiveSyncSettingTab.titleSetupOtherDevices")}`, + undefined, + visibleOnly(() => this.isConfiguredAs("isConfigured", true)) + ).then((panelEl) => { + new Setting(panelEl) + .setName($msg("obsidianLiveSyncSettingTab.nameCopySetupURI")) + .setDesc($msg("obsidianLiveSyncSettingTab.descCopySetupURI")) + .addButton((button) => { + button.setButtonText($msg("obsidianLiveSyncSettingTab.btnCopy")).onClick(() => { + this.requestCopySetupURI(); + }); + }); + new Setting(panelEl) + .setName($msg("Setup.ShowQRCode")) + .setDesc($msg("Setup.ShowQRCode.Desc")) + .addButton((button) => { + button.setButtonText($msg("Setup.ShowQRCode")).onClick(() => { + this.requestShowSetupQRCode(); + }); + }); + }); +} diff --git a/src/modules/features/SettingDialogue/PaneSetup.ts b/src/modules/features/SettingDialogue/PaneSetup.ts deleted file mode 100644 index 485864fa..00000000 --- a/src/modules/features/SettingDialogue/PaneSetup.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { MarkdownRenderer } from "@/deps.ts"; -import { $msg } from "@/common/translation"; -import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; -import { fireAndForget } from "octagonal-wheels/promises"; -import { - EVENT_REQUEST_COPY_SETUP_URI, - EVENT_REQUEST_OPEN_SETUP_URI, - EVENT_REQUEST_SHOW_SETUP_QR, - eventHub, -} from "@/common/events.ts"; -import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.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"; -import { - createCoreSettingsAfterFullReset, - createEditingSettingsAfterFullReset, -} from "@/serviceFeatures/setupObsidian/settingsReset.ts"; -export function paneSetup( - this: ObsidianLiveSyncSettingTab, - paneEl: HTMLElement, - { addPanel, addPane }: PageFunctions -): void { - void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleQuickSetup")).then((paneEl) => { - new Setting(paneEl) - .setName($msg("obsidianLiveSyncSettingTab.nameConnectSetupURI")) - .setDesc($msg("obsidianLiveSyncSettingTab.descConnectSetupURI")) - .addButton((text) => { - text.setButtonText($msg("obsidianLiveSyncSettingTab.btnUse")).onClick(() => { - this.closeSetting(); - eventHub.emitEvent(EVENT_REQUEST_OPEN_SETUP_URI); - }); - }); - - new Setting(paneEl) - .setName($msg("Rerun Onboarding Wizard")) - .setDesc($msg("Rerun the onboarding wizard to set up Self-hosted LiveSync again.")) - .addButton((text) => { - text.setButtonText($msg("Rerun Wizard")).onClick(async () => { - const setupManager = this.core.getModule(SetupManager); - await setupManager.startOnBoarding(); - }); - }); - - new Setting(paneEl) - .setName($msg("obsidianLiveSyncSettingTab.nameEnableLiveSync")) - .setDesc($msg("obsidianLiveSyncSettingTab.descEnableLiveSync")) - .addOnUpdate(visibleOnly(() => !this.isConfiguredAs("isConfigured", true))) - .addButton((text) => { - text.setButtonText($msg("obsidianLiveSyncSettingTab.btnEnable")).onClick(async () => { - this.editingSettings.isConfigured = true; - await this.saveAllDirtySettings(); - this.services.appLifecycle.askRestart(); - }); - }); - }); - - void addPanel( - paneEl, - $msg("obsidianLiveSyncSettingTab.titleSetupOtherDevices"), - undefined, - visibleOnly(() => this.isConfiguredAs("isConfigured", true)) - ).then((paneEl) => { - new Setting(paneEl) - .setName($msg("obsidianLiveSyncSettingTab.nameCopySetupURI")) - .setDesc($msg("obsidianLiveSyncSettingTab.descCopySetupURI")) - .addButton((text) => { - text.setButtonText($msg("obsidianLiveSyncSettingTab.btnCopy")).onClick(() => { - // await this.plugin.addOnSetup.command_copySetupURI(); - eventHub.emitEvent(EVENT_REQUEST_COPY_SETUP_URI); - }); - }); - new Setting(paneEl) - .setName($msg("Setup.ShowQRCode")) - .setDesc($msg("Setup.ShowQRCode.Desc")) - .addButton((text) => { - text.setButtonText($msg("Setup.ShowQRCode")).onClick(() => { - eventHub.emitEvent(EVENT_REQUEST_SHOW_SETUP_QR); - }); - }); - }); - - void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleReset")).then((paneEl) => { - new Setting(paneEl) - .setName($msg("obsidianLiveSyncSettingTab.nameDiscardSettings")) - .addButton((text) => { - setButtonDestructiveState(text) - .setButtonText($msg("obsidianLiveSyncSettingTab.btnDiscard")) - .onClick(async () => { - if ( - (await this.core.confirm.askYesNoDialog( - $msg("obsidianLiveSyncSettingTab.msgDiscardConfirmation"), - { defaultOption: "No" } - )) == "yes" - ) { - this.editingSettings = createEditingSettingsAfterFullReset(this.editingSettings); - await this.saveAllDirtySettings(); - this.core.settings = createCoreSettingsAfterFullReset(); - await this.services.setting.saveSettingData(); - await this.services.database.resetDatabase(); - // await this.plugin.initializeDatabase(); - this.services.appLifecycle.askRestart(); - } - }); - }) - .addOnUpdate(visibleOnly(() => this.isConfiguredAs("isConfigured", true))); - }); - - void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleExtraFeatures")).then((paneEl) => { - new Setting(paneEl).autoWireToggle("useAdvancedMode"); - - new Setting(paneEl).autoWireToggle("usePowerUserMode"); - new Setting(paneEl).autoWireToggle("useEdgeCaseMode"); - - 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"); - const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`; - this.createEl(paneEl, "div", "", (el) => { - el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => { - anchor.href = `https://github.com/${repo}/blob/main${topPath}`; - anchor.target = "_blank"; - anchor.rel = "noopener"; - }); - }); - const troubleShootEl = this.createEl(paneEl, "div", { - text: "", - cls: "sls-troubleshoot-preview", - }); - const loadMarkdownPage = async (pathAll: string, basePathParam: string = "") => { - troubleShootEl.setCssStyles({ minHeight: troubleShootEl.clientHeight + "px" }); - troubleShootEl.empty(); - const fullPath = pathAll.startsWith("/") ? pathAll : `${basePathParam}/${pathAll}`; - - const directoryArr = fullPath.split("/"); - const filename = directoryArr.pop(); - const directly = directoryArr.join("/"); - const basePath = directly; - - let remoteTroubleShootMDSrc = ""; - try { - remoteTroubleShootMDSrc = await request(`${rawRepoURI}${basePath}/${filename}`); - } catch (ex) { - 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)` - ); - // Render markdown - await MarkdownRenderer.render( - this.plugin.app, - ` [${$msg("obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting")}](${topPath}) [${$msg("obsidianLiveSyncSettingTab.linkPageTop")}](${filename})\n\n${remoteTroubleShootMD}`, - troubleShootEl, - `${rawRepoURI}`, - lifetimeComponent - ); - if (pageDisposed) return; - // Menu - troubleShootEl.querySelector(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({ - position: "sticky", - top: "-1em", - backgroundColor: "var(--modal-background)", - }); - // Trap internal links. - troubleShootEl.querySelectorAll("a.internal-link").forEach((anchorEl) => { - anchorEl.addEventListener("click", (evt) => { - fireAndForget(async () => { - const uri = anchorEl.getAttr("data-href"); - if (!uri) return; - if (uri.startsWith("#")) { - evt.preventDefault(); - const elements = Array.from( - troubleShootEl.querySelectorAll("[data-heading]") - ); - const p = elements.find( - (e) => - e.getAttr("data-heading")?.toLowerCase().split(" ").join("-") == - uri.substring(1).toLowerCase() - ); - if (p) { - p.setCssStyles({ scrollMargin: "3em" }); - p.scrollIntoView({ - behavior: "instant", - block: "start", - }); - } - } else { - evt.preventDefault(); - await loadMarkdownPage(uri, basePath); - troubleShootEl.setCssStyles({ scrollMargin: "1em" }); - troubleShootEl.scrollIntoView({ - behavior: "instant", - block: "start", - }); - } - }); - }); - }); - troubleShootEl.setCssStyles({ minHeight: "" }); - }; - void loadMarkdownPage(topPath); - }); -} diff --git a/src/modules/features/SettingDialogue/PaneSyncSettings.ts b/src/modules/features/SettingDialogue/PaneSyncSettings.ts index 13ccce1f..70a4b566 100644 --- a/src/modules/features/SettingDialogue/PaneSyncSettings.ts +++ b/src/modules/features/SettingDialogue/PaneSyncSettings.ts @@ -95,6 +95,7 @@ export function paneSyncSettings( await this.saveAllDirtySettings(); await this.services.control.applySettings(); + this.requestCatalogueRefresh(); }); }); void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleSynchronizationMethod")).then((paneEl) => { @@ -129,6 +130,7 @@ export function paneSyncSettings( await this.saveSettings(["liveSync", "periodicReplication"]); await this.services.control.applySettings(); + this.requestCatalogueRefresh(); }); new Setting(paneEl).autoWireNumeric("periodicReplicationInterval", { @@ -144,6 +146,15 @@ export function paneSyncSettings( new Setting(paneEl).autoWireToggle("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync }); new Setting(paneEl).autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync }); new Setting(paneEl).autoWireToggle("syncAfterMerge", { onUpdate: onlyOnNonLiveSync }); + for (const key of [ + "syncOnSave", + "syncOnEditorSave", + "syncOnFileOpen", + "syncOnStart", + "syncAfterMerge", + ] as const) { + this.addOnSaved(key, () => this.requestCatalogueRefresh()); + } // Desktop app only, and only for the sync modes that keep a background replication channel // (LiveSync and Periodic). Ignored on mobile, where suspending preserves battery. The // visibility predicate mirrors the runtime guard in ModuleObsidianEvents. diff --git a/src/modules/features/SettingDialogue/SettingSpec.unit.spec.ts b/src/modules/features/SettingDialogue/SettingSpec.unit.spec.ts index 0fefe715..5c1c029f 100644 --- a/src/modules/features/SettingDialogue/SettingSpec.unit.spec.ts +++ b/src/modules/features/SettingDialogue/SettingSpec.unit.spec.ts @@ -9,6 +9,7 @@ import { type SettingSpec, } from "./SettingSpec.ts"; import { createAdvancedSettingSpecGroups } from "./AdvancedSettingSpecs.ts"; +import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts"; const rangeMessages = { valueShouldBeInRange: ({ min, max }: { min?: number; max?: number }) => `${min ?? "~"}..${max ?? "~"}`, @@ -74,6 +75,52 @@ describe("Advanced setting specifications", () => { }); }); +describe("General setting specifications", () => { + it("shares every General and Logging control between the imperative and declarative renderers", () => { + const groups = createGeneralSettingSpecGroups({ + showEditorStatusDetails: () => true, + showVerboseLog: () => true, + }); + + expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging"]); + expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([ + "displayLanguage", + "showStatusOnEditor", + "showOnlyIconsOnEditor", + "showStatusOnStatusbar", + "hideFileWarningNotice", + "networkWarningStyle", + "lessInformationInLog", + "showVerboseLog", + ]); + }); + + it("keeps the three feature-level controls together under Extra menus", () => { + const group = createExtraMenuSettingSpecGroup(); + + expect(group.heading).toBe("Extra menus"); + expect(group.items.map(({ key }) => key)).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]); + }); + + it("retains the two conditional visibility rules", () => { + let editorDetails = false; + let verboseLog = false; + const specs = createGeneralSettingSpecGroups({ + showEditorStatusDetails: () => editorDetails, + showVerboseLog: () => verboseLog, + }).flatMap(({ items }) => items); + const editorIcons = specs.find(({ key }) => key === "showOnlyIconsOnEditor"); + const verbose = specs.find(({ key }) => key === "showVerboseLog"); + + expect(editorIcons?.visible?.()).toBe(false); + expect(verbose?.visible?.()).toBe(false); + editorDetails = true; + verboseLog = true; + expect(editorIcons?.visible?.()).toBe(true); + expect(verbose?.visible?.()).toBe(true); + }); +}); + describe("SettingSpec conversion", () => { it("maps metadata and a toggle to an Obsidian definition without importing the runtime API", () => { const visible = vi.fn(() => true); diff --git a/src/modules/features/SettingDialogue/SettingsPageCatalogue.ts b/src/modules/features/SettingDialogue/SettingsPageCatalogue.ts index c8c6b14d..80353da5 100644 --- a/src/modules/features/SettingDialogue/SettingsPageCatalogue.ts +++ b/src/modules/features/SettingDialogue/SettingsPageCatalogue.ts @@ -11,17 +11,23 @@ import { toObsidianSettingDefinition, type PersistedSettingKey, type SettingSpec import { getConfig } from "./settingConstants.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { PageFunctions } from "./SettingPane.ts"; +import { + createExtraMenuSettingSpecGroup, + createGeneralSettingSpecGroups, + type GeneralSettingSpecContext, +} from "./GeneralSettingSpecs.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 { paneHelp } from "./PaneHelp.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 { paneQuickSetup } from "./PaneQuickSetup.ts"; import { paneSyncSettings } from "./PaneSyncSettings.ts"; /** The existing pane renderer used by the imperative settings tab and custom pages. */ @@ -62,13 +68,13 @@ export function createSettingsPageCatalogue(): SettingsPageEntry[] { legacy: paneChangeLog, }, { - id: "setup", - name: () => $msg("obsidianLiveSyncSettingTab.panelSetup"), + id: "quick-setup", + name: () => $msg("obsidianLiveSyncSettingTab.titleQuickSetup"), icon: "🧙‍♂️", order: 110, level: undefined, content: "custom", - legacy: paneSetup, + legacy: paneQuickSetup, }, { id: "general", @@ -160,6 +166,15 @@ export function createSettingsPageCatalogue(): SettingsPageEntry[] { content: "custom", legacy: paneMaintenance, }, + { + id: "help", + name: () => $msg("obsidianLiveSyncSettingTab.titleHelpAndTroubleshooting"), + icon: "❓", + order: 90, + level: undefined, + content: "custom", + legacy: paneHelp, + }, ]; } @@ -169,7 +184,7 @@ const numberRangeMessage = ({ min, max }: { min?: number; max?: number }): strin max: max === undefined ? "~" : `${max}`, }); -function toAdvancedSettingDefinition(spec: SettingSpec): ReturnType { +function toSettingDefinition(spec: SettingSpec): ReturnType { const metadata = getConfig(spec.key); if (!metadata) { throw new Error(`Missing translated setting metadata for ${spec.key}`); @@ -186,6 +201,22 @@ export function createAdvancedSettingDefinitionGroups( return createAdvancedSettingSpecGroups(context).map((group) => ({ type: "group", heading: group.heading, - items: group.items.map(toAdvancedSettingDefinition), + items: group.items.map(toSettingDefinition), })); } + +/** Convert the shared General specifications to native Obsidian groups. */ +export function createGeneralSettingDefinitionGroups( + context: GeneralSettingSpecContext +): SettingDefinitionGroup[] { + return createGeneralSettingSpecGroups(context).map((group) => ({ + type: "group", + heading: group.heading, + items: group.items.map(toSettingDefinition), + })); +} + +/** Convert the Extra menus feature-level controls to native Obsidian settings. */ +export function createExtraMenuSettingDefinitions(): ReturnType[] { + return createExtraMenuSettingSpecGroup().items.map(toSettingDefinition); +} diff --git a/src/modules/features/SettingDialogue/SettingsPageCatalogue.unit.spec.ts b/src/modules/features/SettingDialogue/SettingsPageCatalogue.unit.spec.ts index aed64d20..eacfdd27 100644 --- a/src/modules/features/SettingDialogue/SettingsPageCatalogue.unit.spec.ts +++ b/src/modules/features/SettingDialogue/SettingsPageCatalogue.unit.spec.ts @@ -5,7 +5,8 @@ vi.mock("@/common/translation", () => ({ translateLiveSyncMessage: (key: string) => key, })); vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() })); -vi.mock("./PaneSetup.ts", () => ({ paneSetup: vi.fn() })); +vi.mock("./PaneQuickSetup.ts", () => ({ paneQuickSetup: vi.fn() })); +vi.mock("./PaneHelp.ts", () => ({ paneHelp: vi.fn() })); vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: vi.fn() })); vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() })); vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() })); @@ -25,7 +26,7 @@ describe("settings page catalogue", () => { expect(catalogue.map(({ id }) => id)).toEqual([ "change-log", - "setup", + "quick-setup", "general", "remote-configuration", "synchronisation", @@ -36,11 +37,18 @@ describe("settings page catalogue", () => { "power-users", "patches", "maintenance", + "help", ]); 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); + expect(catalogue.filter(({ content }) => content === "custom")).toHaveLength(12); + expect(catalogue.find(({ id }) => id === "quick-setup")?.name()).toBe( + "obsidianLiveSyncSettingTab.titleQuickSetup" + ); + expect(catalogue.find(({ id }) => id === "help")?.name()).toBe( + "obsidianLiveSyncSettingTab.titleHelpAndTroubleshooting" + ); }); it("registers each Advanced control key exactly once", () => { diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index c68d22ea..9f9ca382 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -102,11 +102,11 @@ The underlying `test:e2e:obsidian:` scripts remain available for an im `test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change. -`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from **Self-hosted LiveSync settings** → **Setup** on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. +`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from Quick Setup in either settings interface. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. -`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it opens all 12 pages from the native page catalogue, searches globally for the Advanced control, captures the catalogue, search result, and Advanced page, and verifies that the value remains after the settings dialogue is closed and reopened. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour. +`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour. The default runner uses the maintained pre-1.13 Obsidian fixture and therefore exercises the complete compatibility-review, mobile-layout, and imperative-settings path. To exercise only the native settings contract against an additional Obsidian 1.13-or-later installation, supply its executable and companion CLI explicitly: @@ -117,7 +117,7 @@ E2E_OBSIDIAN_SETTINGS_ONLY=true \ npm run test:e2e:obsidian:settings-ui ``` -The native run writes `settings-declarative-catalogue.png`, `settings-declarative-search.png`, and `settings-declarative-advanced.png` to `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. All settings E2E scenarios open pages through the shared navigator in `runner/ui.ts`; scenario code must not select the legacy tab menu directly. +The native run writes `settings-declarative-landing.png`, `settings-declarative-search.png`, `settings-declarative-advanced.png`, and `settings-declarative-landing-mobile.png` to `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. All settings E2E scenarios open pages through the shared navigator in `runner/ui.ts`; scenario code must not select the legacy tab menu directly. The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay. @@ -235,7 +235,7 @@ Useful environment variables: - `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds. - `E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS`: timeout for each visible revision-repair control and result; default is 15 seconds. - `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds. -- `E2E_OBSIDIAN_SETTINGS_ONLY=true`: skip compatibility-review and mobile-layout coverage when running `settings-ui` against an additional Obsidian 1.13-or-later installation. +- `E2E_OBSIDIAN_SETTINGS_ONLY=true`: skip compatibility-review and its dialogue-layout coverage when running `settings-ui` against an additional Obsidian 1.13-or-later installation; the native settings mobile-landing check still runs. - `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds. - `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds. - `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds. diff --git a/test/e2e-obsidian/runner/mobileUi.ts b/test/e2e-obsidian/runner/mobileUi.ts index d492092d..bd80ede3 100644 --- a/test/e2e-obsidian/runner/mobileUi.ts +++ b/test/e2e-obsidian/runner/mobileUi.ts @@ -70,9 +70,7 @@ async function applyObsidianMobileTestMode( await mkdir(outputDirectory, { recursive: true }); const screenshotPath = join( outputDirectory, - waitForLiveSync - ? "mobile-mode-transition.failure.png" - : "mobile-mode-before-plugin-start.failure.png" + waitForLiveSync ? "mobile-mode-transition.failure.png" : "mobile-mode-before-plugin-start.failure.png" ); await page.screenshot({ path: screenshotPath, fullPage: true }); const detail = error instanceof Error ? error.message : String(error); @@ -108,7 +106,8 @@ export async function setObsidianMobileTestMode(port: number, enabled: boolean, export async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise { const dialogue = container.locator(".modal").last(); - const closeButton = dialogue.locator(".modal-close-button"); + // Obsidian 1.13 moved and renamed the mobile close control; both selectors remain supported runtime contracts. + const closeButton = container.locator(".modal-close-button, .modal-header-button").last(); await assertLocatorWithinViewport(page, dialogue, { label }); await assertNoHorizontalOverflow(page, dialogue, { label }); await assertLocatorWithinSafeArea(page, dialogue, { diff --git a/test/e2e-obsidian/runner/ui.ts b/test/e2e-obsidian/runner/ui.ts index ee338b8d..89a6c792 100644 --- a/test/e2e-obsidian/runner/ui.ts +++ b/test/e2e-obsidian/runner/ui.ts @@ -67,6 +67,19 @@ export async function captureObsidianDialogue( return await captureObsidianPage(port, filename, assertReady); } +/** Wait for a visible Obsidian dialogue, including one opened outside the settings window. */ +export async function waitForVisibleObsidianDialogue(page: Page, text: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const candidate of page.context().pages()) { + const dialogue = candidate.locator(".modal-container").filter({ hasText: text }).last(); + if (await dialogue.isVisible().catch(() => false)) return dialogue; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Obsidian did not open the expected dialogue: ${text}`); +} + function declarativePageEntry(dialogue: Locator, name: string): Locator { return dialogue .locator(".setting-item.mod-navigable") @@ -85,7 +98,19 @@ function escapeRegExp(value: string): string { * owns that renderer difference for both supported settings implementations. */ export async function openLiveSyncSettings(page: Page, timeoutMs = 10_000): Promise { - await page.evaluate(() => { + let hostPage: Page | undefined; + for (const candidate of page.context().pages()) { + const hasSettingsHost = await candidate + .evaluate(() => (globalThis as ObsidianSettingsHost).app?.setting !== undefined) + .catch(() => false); + if (hasSettingsHost) { + hostPage = candidate; + break; + } + } + if (hostPage === undefined) throw new Error("Obsidian settings are unavailable"); + + await hostPage.evaluate(() => { const host = globalThis as ObsidianSettingsHost; const setting = host.app?.setting; if (setting === undefined) throw new Error("Obsidian settings are unavailable"); @@ -164,14 +189,14 @@ export async function openLiveSyncSettings(page: Page, timeoutMs = 10_000): Prom }; const close = async (): Promise => { - await page + await hostPage .evaluate(() => { const setting = (globalThis as ObsidianSettingsHost).app?.setting; if (setting === undefined) throw new Error("Obsidian settings are unavailable"); setTimeout(() => setting.close(), 0); }) .catch((error: unknown) => { - if (!page.isClosed() && !settingsPage.isClosed()) throw error; + if (!hostPage.isClosed() && !settingsPage.isClosed()) throw error; }); await Promise.race([ dialogue.waitFor({ state: "hidden", timeout: timeoutMs }), diff --git a/test/e2e-obsidian/scripts/onboarding-invitation.ts b/test/e2e-obsidian/scripts/onboarding-invitation.ts index 878f0aad..17f2b044 100644 --- a/test/e2e-obsidian/scripts/onboarding-invitation.ts +++ b/test/e2e-obsidian/scripts/onboarding-invitation.ts @@ -13,6 +13,7 @@ import { captureObsidianDialogue, obsidianRemoteDebuggingPort, openLiveSyncSettings, + waitForVisibleObsidianDialogue, withObsidianPage, } from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; @@ -155,16 +156,23 @@ async function captureAndCloseIntro(filename: string, mobile: boolean): Promise< async function openOnboardingFromSettings(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); - const liveSyncSettings = await settingsNavigator.openPage("Setup"); + const liveSyncSettings = + settingsNavigator.renderer === "declarative" + ? (await settingsNavigator.returnToCatalogue(), settingsNavigator.dialogue) + : await settingsNavigator.openPage("Quick Setup"); const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({ has: settingsNavigator.page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }), }); await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); - await onboardingSetting - .getByRole("button", { name: "Rerun Wizard", exact: true }) - .click({ timeout: uiTimeoutMs }); - await onboardingDialogue(settingsNavigator.page).waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (settingsNavigator.renderer === "declarative") { + await onboardingSetting.click({ timeout: uiTimeoutMs }); + } else { + await onboardingSetting + .getByRole("button", { name: "Rerun Wizard", exact: true }) + .click({ timeout: uiTimeoutMs }); + } + await waitForVisibleObsidianDialogue(settingsNavigator.page, "Welcome to Self-hosted LiveSync", uiTimeoutMs); }); } diff --git a/test/e2e-obsidian/scripts/settings-ui.ts b/test/e2e-obsidian/scripts/settings-ui.ts index c33a9403..c0766202 100644 --- a/test/e2e-obsidian/scripts/settings-ui.ts +++ b/test/e2e-obsidian/scripts/settings-ui.ts @@ -10,9 +10,11 @@ import { obsidianRemoteDebuggingPort, openLiveSyncSettings, preseedTrustedVaultState, + waitForVisibleObsidianDialogue, withObsidianPage, } from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; +import type { Locator } from "playwright"; const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000); const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true"; @@ -49,20 +51,110 @@ type ObsidianTestApp = { type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; const settingsPageNames = [ - "Change Log", - "Setup", - "General Settings", + "Appearance", + "Logging", + "Extra menus", "Remote Configuration", "Sync Settings", + "Maintenance", + "Hatch", "Selector", "Customisation sync", - "Hatch", "Advanced", "Power users", "Patches", - "Maintenance", + "Help and troubleshooting", + "Change Log", ] as const; +async function assertDeclarativeLandingOrder(root: Locator): Promise { + const labels = [ + "Quick Setup", + "Synchronisation", + "Remote Configuration", + "Sync Settings", + "General Settings", + "Appearance", + "Logging", + "Extra menus", + "📲 Set up other devices", + "Maintenance and recovery", + "Maintenance", + "Hatch", + "Extra features", + "Selector", + "Customisation sync", + "Advanced settings", + "Advanced", + "Power users", + "Patches", + "Help and information", + "Help and troubleshooting", + "Change Log", + ]; + await root + .locator(".vertical-tab-content:visible") + .last() + .evaluate((container, expectedLabels) => { + const labelledElements = Array.from( + container.querySelectorAll(".setting-item-heading, .setting-item-name, h1, h2, h3, h4") + ); + const matchingElements = expectedLabels.map((label) => { + const match = labelledElements.find((element) => element.textContent?.trim().endsWith(label)); + if (!match) throw new Error(`The settings landing page did not contain '${label}'.`); + return match; + }); + for (let index = 1; index < matchingElements.length; index++) { + const previous = matchingElements[index - 1]; + const current = matchingElements[index]; + if (!(previous.compareDocumentPosition(current) & Node.DOCUMENT_POSITION_FOLLOWING)) { + throw new Error(`The settings landing page was not ordered as ${expectedLabels.join(" -> ")}.`); + } + } + }, labels); +} + +async function scrollDeclarativeLandingToTop(root: Locator): Promise { + const quickSetupHeading = root.locator(".setting-item-heading").filter({ hasText: "Quick Setup" }).first(); + await quickSetupHeading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await quickSetupHeading.scrollIntoViewIfNeeded(); +} + +async function captureDeclarativeMobileLanding(): Promise { + const port = obsidianRemoteDebuggingPort(); + await setObsidianMobileTestMode(port, true, uiTimeoutMs); + try { + return await withObsidianPage(port, async (page) => { + const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs); + if (settingsNavigator.renderer !== "declarative") { + await settingsNavigator.close(); + return undefined; + } + await settingsNavigator.returnToCatalogue(); + await scrollDeclarativeLandingToTop(settingsNavigator.dialogue); + await assertDeclarativeLandingOrder(settingsNavigator.dialogue); + const remoteConfiguration = settingsNavigator.dialogue + .locator(".setting-item-name") + .filter({ hasText: "Remote Configuration" }) + .first(); + await remoteConfiguration.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const path = `${diagnosticsDirectory}/settings-declarative-landing-mobile.png`; + await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path }); + const remotePosition = await remoteConfiguration.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return { top: bounds.top, bottom: bounds.bottom, viewportHeight: window.innerHeight }; + }); + if (remotePosition.top < 0 || remotePosition.bottom > remotePosition.viewportHeight) { + throw new Error("Remote Configuration was not visible at the top of the mobile settings landing page."); + } + await settingsNavigator.close(); + return path; + }); + } finally { + await setObsidianMobileTestMode(port, false, uiTimeoutMs); + } +} + async function resumePendingCompatibilityReviewForSettings(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { const review = page.locator(".modal-container").filter({ @@ -307,7 +399,9 @@ async function verifyEffectiveSettings(): Promise { throw new Error("The desktop sleep preference must be enabled by default."); } - settingsPage = await settingsNavigator.openPage("Setup"); + settingsPage = await settingsNavigator.openPage( + settingsNavigator.renderer === "declarative" ? "Extra menus" : "General Settings" + ); const advancedModeSetting = settingsPage.locator(".setting-item").filter({ has: settingsNavigator.page.getByText("Enable advanced features", { exact: true }), }); @@ -367,16 +461,28 @@ async function verifyEffectiveSettings(): Promise { if (settingsNavigator.renderer === "declarative") { await settingsNavigator.returnToCatalogue(); - await settingsNavigator.dialogue - .locator(".vertical-tab-content:visible") - .last() - .evaluate((element) => { - element.scrollTop = 0; - }); + await scrollDeclarativeLandingToTop(settingsNavigator.dialogue); await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, - path: `${diagnosticsDirectory}/settings-declarative-catalogue.png`, + path: `${diagnosticsDirectory}/settings-declarative-landing.png`, }); + await assertDeclarativeLandingOrder(settingsNavigator.dialogue); + const rerunOnboarding = settingsNavigator.dialogue + .locator(".setting-item-name") + .filter({ hasText: "Rerun Onboarding Wizard" }) + .first(); + await rerunOnboarding + .locator("xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' setting-item ')][1]") + .click({ timeout: uiTimeoutMs }); + const onboarding = await waitForVisibleObsidianDialogue( + page, + "Welcome to Self-hosted LiveSync", + uiTimeoutMs + ); + await onboarding + .getByRole("button", { name: "No, please take me back", exact: true }) + .click({ timeout: uiTimeoutMs }); + await onboarding.waitFor({ state: "hidden", timeout: uiTimeoutMs }); const search = settingsNavigator.dialogue.locator(".setting-search-container input"); await search.fill("Memory cache size (by total items)"); const searchResult = settingsNavigator.dialogue.locator(".setting-search-result-item").filter({ @@ -433,6 +539,8 @@ async function verifyEffectiveSettings(): Promise { throw new Error(`The declarative Advanced value was not restored after reopening: ${restoredValue}`); } } + + await settingsNavigator.close(); }); } @@ -485,6 +593,8 @@ async function main(): Promise { await verifyConfigDoctorFollowsCompatibilityReview(); } await verifyEffectiveSettings(); + const mobileLanding = await captureDeclarativeMobileLanding(); + if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`); console.log("Compatibility review and settings expose only effective user controls."); } finally { if (session) { diff --git a/updates.md b/updates.md index 02eea44e..7bcf798e 100644 --- a/updates.md +++ b/updates.md @@ -14,9 +14,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ### Interface and translation +#### Fixed + +- Compatibility pause warnings now direct you to the dedicated compatibility review instead of the Change Log. + #### Improved -- Settings pages and Advanced controls now use Obsidian 1.13's native page navigation and global settings search, while retaining their familiar icons. Earlier supported Obsidian versions continue to use the existing settings interface. +- Settings pages, General controls, Quick Setup actions, and Advanced controls now use Obsidian 1.13's native settings interface and global search, while retaining their familiar icons. The landing page keeps Remote Configuration and Sync Settings together, places Appearance, Logging, and Extra menus under General Settings, and groups maintenance, optional features, advanced settings, and help by purpose. Earlier supported Obsidian versions continue to use the pane-based interface. ## 1.0.18