Compare commits

..
9 Commits
Author SHA1 Message Date
vorotamoroz 011a8405b5 Merge pull request #1139 from vrtmrz/1_0_21
Releasing 1.0.21
2026-08-26 20:36:43 +09:00
vorotamoroz f5f7aab11f Polish 1.0.21 release notes 2026-08-26 11:04:15 +00:00
github-actions[bot] 9854319c96 Releasing 1.0.21 2026-08-26 10:43:35 +00:00
vorotamoroz 4ab0689c7f Merge pull request #1138 from vrtmrz/fix/mobile-settings-layout
Fix settings layout on narrow mobile screens
2026-08-26 15:26:08 +09:00
vorotamoroz edeac6f7e2 Separate settings component style helpers 2026-08-26 05:49:37 +00:00
vorotamoroz 783fbb8f23 Refine mobile settings action layout markers 2026-08-26 05:13:38 +00:00
vorotamoroz 8644af6128 Narrow mobile settings layout fix 2026-08-26 04:30:44 +00:00
vorotamoroz 4ff5b4dfe8 Fix mobile settings layout overflow 2026-08-25 17:27:31 +00:00
vorotamoroz 0b1f5ca719 Merge pull request #1137 from vrtmrz/1_0_20
Releasing 1.0.20
2026-08-26 00:06:07 +09:00
22 changed files with 533 additions and 154 deletions
+16
View File
@@ -2,6 +2,22 @@
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md). This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
## 1.0.15
15th August, 2026
### Synchronisation and storage
#### Improved
- Start-up offline scanning is now faster, especially for larger Vaults using path obfuscation (Commonlib 0.1.15).
### Interface and translation
#### Improved
- The Traditional Chinese translation catalogue has been completed and polished for broader coverage and more natural, consistent terminology (PR #1106). Thank you to @nimula for the contribution!
## 1.0.14 ## 1.0.14
14th August, 2026 14th August, 2026
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "1.0.20", "version": "1.0.21",
"minAppVersion": "1.7.2", "minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz", "author": "vorotamoroz",
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.20", "version": "1.0.21",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.20", "version": "1.0.21",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"src/apps/cli", "src/apps/cli",
@@ -15813,7 +15813,7 @@
}, },
"src/apps/cli": { "src/apps/cli": {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"version": "1.0.20-cli", "version": "1.0.21-cli",
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"minimatch": "^10.2.5", "minimatch": "^10.2.5",
@@ -15838,7 +15838,7 @@
}, },
"src/apps/webapp": { "src/apps/webapp": {
"name": "livesync-webapp", "name": "livesync-webapp",
"version": "1.0.20-webapp", "version": "1.0.21-webapp",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.53" "octagonal-wheels": "^0.1.53"
}, },
@@ -15850,7 +15850,7 @@
} }
}, },
"src/apps/webpeer": { "src/apps/webpeer": {
"version": "1.0.20-webpeer", "version": "1.0.21-webpeer",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.53" "octagonal-wheels": "^0.1.53"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.20", "version": "1.0.21",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"private": true, "private": true,
"version": "1.0.20-cli", "version": "1.0.21-cli",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "livesync-webapp", "name": "livesync-webapp",
"private": true, "private": true,
"version": "1.0.20-webapp", "version": "1.0.21-webapp",
"type": "module", "type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API", "description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "webpeer", "name": "webpeer",
"private": true, "private": true,
"version": "1.0.20-webpeer", "version": "1.0.21-webpeer",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -24,7 +24,8 @@ import {
type AllBooleanItemKey, type AllBooleanItemKey,
} from "./settingConstants.ts"; } from "./settingConstants.ts";
import { $msg } from "@/common/translation"; import { $msg } from "@/common/translation";
import { setButtonDestructiveState, wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts"; import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
export class LiveSyncSetting extends Setting { export class LiveSyncSetting extends Setting {
autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent; autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent;
@@ -24,7 +24,8 @@ import {
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts"; import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts"; import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { setButtonDestructiveState, type PageFunctions } from "./SettingPane.ts"; import type { PageFunctions } from "./SettingPane.ts";
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import { import {
chooseAndCopyFileDatabaseInfo, chooseAndCopyFileDatabaseInfo,
@@ -10,7 +10,8 @@ import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import { setButtonDestructiveState, visibleOnly, type PageFunctions } from "./SettingPane"; import { visibleOnly, type PageFunctions } from "./SettingPane";
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
export function paneMaintenance( export function paneMaintenance(
this: ObsidianLiveSyncSettingTab, this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement, paneEl: HTMLElement,
@@ -10,6 +10,7 @@ import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts"; import type { PageFunctions } from "./SettingPane.ts";
import { visibleOnly } from "./SettingPane.ts"; import { visibleOnly } from "./SettingPane.ts";
import { setButtonAdditionalActionState, setSettingAdditionalActionsState } from "./settingComponentStyles.ts";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { migrateDatabases } from "./settingUtils.ts"; import { migrateDatabases } from "./settingUtils.ts";
@@ -177,7 +178,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch"); new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch");
}); });
void addPanel(paneEl, "Remediation").then((paneEl) => { void addPanel(paneEl, "Remediation").then((paneEl) => {
const setting = new Setting(paneEl); const setting = setSettingAdditionalActionsState(new Setting(paneEl));
const dateEl = setting.controlEl.createSpan(); const dateEl = setting.controlEl.createSpan();
setting setting
.addText((text) => { .addText((text) => {
@@ -215,6 +216,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
}) })
.setAuto("maxMTimeForReflectEvents") .setAuto("maxMTimeForReflectEvents")
.addApplyButton(["maxMTimeForReflectEvents"]); .addApplyButton(["maxMTimeForReflectEvents"]);
if (setting.applyButtonComponent) {
setButtonAdditionalActionState(setting.applyButtonComponent);
}
this.addOnSaved("maxMTimeForReflectEvents", async (key) => { this.addOnSaved("maxMTimeForReflectEvents", async (key) => {
const buttons = ["Restart Now", "Later"] as const; const buttons = ["Restart Now", "Later"] as const;
@@ -12,17 +12,33 @@ const remediationHarness = vi.hoisted(() => {
onChange: vi.fn(), onChange: vi.fn(),
setValue: vi.fn(), setValue: vi.fn(),
}; };
const setButtonClassState = vi.fn();
const setSettingClassState = vi.fn();
return { return {
createSpan, createSpan,
dateElement, dateElement,
inputEl, inputEl,
setButtonClassState,
setSettingClassState,
textComponent, textComponent,
}; };
}); });
vi.mock("./LiveSyncSetting.ts", () => ({ vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class LiveSyncSetting { LiveSyncSetting: class LiveSyncSetting {
applyButtonComponent = {
buttonEl: {
classList: {
toggle: remediationHarness.setButtonClassState,
},
},
};
settingEl = {
classList: {
toggle: remediationHarness.setSettingClassState,
},
};
controlEl = { controlEl = {
createSpan: remediationHarness.createSpan, createSpan: remediationHarness.createSpan,
}; };
@@ -93,5 +109,10 @@ describe("panePatches remediation setting", () => {
expect(createSpan).not.toHaveBeenCalled(); expect(createSpan).not.toHaveBeenCalled();
expect(remediationHarness.createSpan).toHaveBeenCalledOnce(); expect(remediationHarness.createSpan).toHaveBeenCalledOnce();
expect(remediationHarness.dateElement.textContent).toBe("No limit configured"); expect(remediationHarness.dateElement.textContent).toBe("No limit configured");
expect(remediationHarness.setSettingClassState).toHaveBeenCalledWith(
"sls-setting-with-additional-actions",
true
);
expect(remediationHarness.setButtonClassState).toHaveBeenCalledWith("sls-setting-additional-action", true);
}); });
}); });
@@ -11,7 +11,12 @@ import { Menu, type ButtonComponent } from "@/deps.ts";
import { $msg } from "@/common/translation"; import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { setButtonDestructiveState, type PageFunctions } from "./SettingPane.ts"; import {
setButtonAdditionalActionState,
setButtonDestructiveState,
setSettingAdditionalActionsState,
} from "./settingComponentStyles.ts";
import type { PageFunctions } from "./SettingPane.ts";
// import { visibleOnly } from "./SettingPane.ts"; // import { visibleOnly } from "./SettingPane.ts";
import InfoPanel from "./InfoPanel.svelte"; import InfoPanel from "./InfoPanel.svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
@@ -105,7 +110,7 @@ export function paneRemoteConfig(
void addPanel(paneEl, "E2EE Configuration", () => {}).then((paneEl) => { void addPanel(paneEl, "E2EE Configuration", () => {}).then((paneEl) => {
const infoPanel = new SveltePanel(InfoPanel, paneEl, E2EESummaryWritable); const infoPanel = new SveltePanel(InfoPanel, paneEl, E2EESummaryWritable);
this.lifetimeComponent.register(() => infoPanel.destroy()); this.lifetimeComponent.register(() => infoPanel.destroy());
const setupButton = new Setting(paneEl).setName("Configure E2EE"); const setupButton = setSettingAdditionalActionsState(new Setting(paneEl).setName("Configure E2EE"));
setupButton setupButton
.addButton((button) => .addButton((button) =>
setButtonDestructiveState(button) setButtonDestructiveState(button)
@@ -118,7 +123,7 @@ export function paneRemoteConfig(
.setButtonText("Configure") .setButtonText("Configure")
) )
.addButton((button) => .addButton((button) =>
setButtonDestructiveState(button) setButtonDestructiveState(setButtonAdditionalActionState(button))
.onClick(async () => { .onClick(async () => {
const setupManager = this.core.getModule(SetupManager); const setupManager = this.core.getModule(SetupManager);
const originalSettings = getSettingsFromEditingSettings(this.editingSettings); const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
@@ -1,7 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({ const runtime = vi.hoisted(() => ({
buttonClasses: [] as string[],
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>, panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
settingClasses: [] as string[],
})); }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({ vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
@@ -21,6 +23,13 @@ vi.mock("@/common/translation", () => ({
vi.mock("./LiveSyncSetting.ts", () => ({ vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class { LiveSyncSetting: class {
nameEl = { addClass: vi.fn(), appendText: vi.fn() }; nameEl = { addClass: vi.fn(), appendText: vi.fn() };
settingEl = {
classList: {
toggle: (value: string, enabled: boolean) => {
if (enabled) runtime.settingClasses.push(value);
},
},
};
setName() { setName() {
return this; return this;
@@ -30,7 +39,26 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this; return this;
} }
addButton() { addButton(callback: (button: unknown) => void) {
const button = {
buttonEl: {
classList: {
toggle: (value: string, enabled: boolean) => {
if (enabled) runtime.buttonClasses.push(value);
},
},
},
setDestructive() {
return this;
},
onClick() {
return this;
},
setButtonText() {
return this;
},
};
callback(button);
return this; return this;
} }
@@ -85,7 +113,9 @@ function createPanelElement(): HTMLElement {
} }
afterEach(() => { afterEach(() => {
runtime.buttonClasses.length = 0;
runtime.panels.length = 0; runtime.panels.length = 0;
runtime.settingClasses.length = 0;
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -96,7 +126,13 @@ describe("paneRemoteConfig", () => {
register: vi.fn((callback: () => unknown) => callbacks.push(callback)), register: vi.fn((callback: () => unknown) => callbacks.push(callback)),
unload: vi.fn(() => callbacks.splice(0).forEach((callback) => callback())), unload: vi.fn(() => callbacks.splice(0).forEach((callback) => callback())),
}; };
const addPanel = vi.fn((_parent: HTMLElement, _heading: string) => Promise.resolve(createPanelElement())); const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
then(callback: (paneEl: HTMLElement) => void) {
if (heading === "E2EE Configuration") {
callback(createPanelElement());
}
},
}));
const host = { const host = {
editingSettings: { remoteConfigurations: {} }, editingSettings: { remoteConfigurations: {} },
core: { settings: { remoteConfigurations: {} } }, core: { settings: { remoteConfigurations: {} } },
@@ -105,6 +141,8 @@ describe("paneRemoteConfig", () => {
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never); paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(runtime.panels).toHaveLength(1)); await vi.waitFor(() => expect(runtime.panels).toHaveLength(1));
expect(runtime.settingClasses).toContain("sls-setting-with-additional-actions");
expect(runtime.buttonClasses).toEqual(["sls-setting-additional-action"]);
lifetimeComponent.unload(); lifetimeComponent.unload();
@@ -6,7 +6,6 @@ import {
type ConfigLevel, type ConfigLevel,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { AllSettingItemKey, AllSettings } from "./settingConstants"; import type { AllSettingItemKey, AllSettings } from "./settingConstants";
import type { ButtonComponent } from "@/deps.ts";
export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => { export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => {
return () => ({ return () => ({
@@ -39,25 +38,6 @@ export function setStyle(el: HTMLElement, styleHead: string, condition: () => bo
} }
} }
/**
* Applies destructive-action styling without requiring Obsidian 1.13 at
* runtime. Older supported versions used the `mod-warning` class for the same
* presentation.
*/
export function setButtonDestructiveState(button: ButtonComponent, isDestructive = true): ButtonComponent {
const compatibleButton = button as unknown as {
setDestructive?: () => ButtonComponent;
removeDestructive?: () => ButtonComponent;
};
const updateNativeStyle = isDestructive ? compatibleButton.setDestructive : compatibleButton.removeDestructive;
if (typeof updateNativeStyle === "function") {
updateNativeStyle.call(button);
} else {
button.buttonEl.classList.toggle("mod-warning", isDestructive);
}
return button;
}
export function visibleOnly(cond: () => boolean): OnUpdateFunc { export function visibleOnly(cond: () => boolean): OnUpdateFunc {
return () => ({ return () => ({
visibility: cond(), visibility: cond(),
@@ -1,44 +0,0 @@
import type { ButtonComponent } from "@/deps.ts";
import { describe, expect, it, vi } from "vitest";
import { setButtonDestructiveState } from "./SettingPane.ts";
type CompatibleButton = ButtonComponent & {
setDestructive?: () => ButtonComponent;
removeDestructive?: () => ButtonComponent;
};
function createButton(overrides: Partial<CompatibleButton> = {}): CompatibleButton {
return {
buttonEl: {
classList: {
toggle: vi.fn(),
},
},
...overrides,
} as unknown as CompatibleButton;
}
describe("setButtonDestructiveState", () => {
it("uses the native destructive-button API when it is available", () => {
const setDestructive = vi.fn();
const removeDestructive = vi.fn();
const button = createButton({ setDestructive, removeDestructive });
expect(setButtonDestructiveState(button, true)).toBe(button);
expect(setButtonDestructiveState(button, false)).toBe(button);
expect(setDestructive).toHaveBeenCalledOnce();
expect(removeDestructive).toHaveBeenCalledOnce();
expect(button.buttonEl.classList.toggle).not.toHaveBeenCalled();
});
it("uses the legacy warning class when the native API is unavailable", () => {
const button = createButton();
setButtonDestructiveState(button, true);
setButtonDestructiveState(button, false);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(1, "mod-warning", true);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(2, "mod-warning", false);
});
});
@@ -0,0 +1,35 @@
import type { ButtonComponent, Setting } from "@/deps.ts";
const SETTING_WITH_ADDITIONAL_ACTIONS_CLASS = "sls-setting-with-additional-actions";
const ADDITIONAL_ACTION_CLASS = "sls-setting-additional-action";
/**
* Applies destructive-action styling without requiring Obsidian 1.13 at
* runtime. Older supported versions used the `mod-warning` class for the same
* presentation.
*/
export function setButtonDestructiveState<T extends ButtonComponent>(button: T, isDestructive = true): T {
const compatibleButton = button as unknown as {
setDestructive?: () => ButtonComponent;
removeDestructive?: () => ButtonComponent;
};
const updateNativeStyle = isDestructive ? compatibleButton.setDestructive : compatibleButton.removeDestructive;
if (typeof updateNativeStyle === "function") {
updateNativeStyle.call(button);
} else {
button.buttonEl.classList.toggle("mod-warning", isDestructive);
}
return button;
}
/** Sets whether a setting row contains actions which may move onto a later line. */
export function setSettingAdditionalActionsState<T extends Setting>(setting: T, hasAdditionalActions = true): T {
setting.settingEl.classList.toggle(SETTING_WITH_ADDITIONAL_ACTIONS_CLASS, hasAdditionalActions);
return setting;
}
/** Sets whether a button is an additional action which may move onto a later line. */
export function setButtonAdditionalActionState<T extends ButtonComponent>(button: T, isAdditionalAction = true): T {
button.buttonEl.classList.toggle(ADDITIONAL_ACTION_CLASS, isAdditionalAction);
return button;
}
@@ -0,0 +1,88 @@
import type { ButtonComponent, Setting } from "@/deps.ts";
import { describe, expect, it, vi } from "vitest";
import {
setButtonAdditionalActionState,
setButtonDestructiveState,
setSettingAdditionalActionsState,
} from "./settingComponentStyles.ts";
type CompatibleButton = ButtonComponent & {
setDestructive?: () => ButtonComponent;
removeDestructive?: () => ButtonComponent;
};
function createButton(overrides: Partial<CompatibleButton> = {}): CompatibleButton {
return {
buttonEl: {
classList: {
toggle: vi.fn(),
},
},
...overrides,
} as unknown as CompatibleButton;
}
function createSetting(): Setting {
return {
settingEl: {
classList: {
toggle: vi.fn(),
},
},
} as unknown as Setting;
}
describe("setSettingAdditionalActionsState", () => {
it("sets whether the supplied setting row contains additional actions", () => {
const setting = createSetting();
expect(setSettingAdditionalActionsState(setting, true)).toBe(setting);
expect(setSettingAdditionalActionsState(setting, false)).toBe(setting);
expect(setting.settingEl.classList.toggle).toHaveBeenNthCalledWith(
1,
"sls-setting-with-additional-actions",
true
);
expect(setting.settingEl.classList.toggle).toHaveBeenNthCalledWith(
2,
"sls-setting-with-additional-actions",
false
);
});
});
describe("setButtonAdditionalActionState", () => {
it("sets whether the supplied button is an additional action", () => {
const button = createButton();
expect(setButtonAdditionalActionState(button, true)).toBe(button);
expect(setButtonAdditionalActionState(button, false)).toBe(button);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(1, "sls-setting-additional-action", true);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(2, "sls-setting-additional-action", false);
});
});
describe("setButtonDestructiveState", () => {
it("uses the native destructive-button API when it is available", () => {
const setDestructive = vi.fn();
const removeDestructive = vi.fn();
const button = createButton({ setDestructive, removeDestructive });
expect(setButtonDestructiveState(button, true)).toBe(button);
expect(setButtonDestructiveState(button, false)).toBe(button);
expect(setDestructive).toHaveBeenCalledOnce();
expect(removeDestructive).toHaveBeenCalledOnce();
expect(button.buttonEl.classList.toggle).not.toHaveBeenCalled();
});
it("uses the legacy warning class when the native API is unavailable", () => {
const button = createButton();
setButtonDestructiveState(button, true);
setButtonDestructiveState(button, false);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(1, "mod-warning", true);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(2, "mod-warning", false);
});
});
+19 -2
View File
@@ -536,14 +536,31 @@ div.workspace-leaf-content[data-type="bases"] .livesync-status {
} }
.sls-setting-panel-title { .sls-setting-panel-title {
position: sticky;
font-size: medium; font-size: medium;
top: 2.5em;
background-color: var(--background-secondary-alt); background-color: var(--background-secondary-alt);
border-radius: 10px; border-radius: 10px;
padding: 0.5em 1em; padding: 0.5em 1em;
} }
body.is-mobile .sls-setting button {
max-width: 100%;
white-space: normal;
}
body.is-mobile .sls-setting-with-additional-actions {
flex-wrap: wrap;
}
body.is-mobile .sls-setting-with-additional-actions .setting-item-control {
min-width: 0;
flex: 1 1 100%;
flex-wrap: wrap;
}
body.is-mobile .sls-setting .sls-setting-additional-action {
flex: 1 1 12rem;
}
.active-pane .sls-setting-panel-title { .active-pane .sls-setting-panel-title {
border: 1px solid var(--interactive-accent); border: 1px solid var(--interactive-accent);
} }
+271 -52
View File
@@ -1,8 +1,12 @@
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import {
assertMobileDialogueLayout,
setObsidianMobileTestMode,
setObsidianMobileTestModeBeforePluginStart,
} from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { import {
allowPendingObsidianTestVaultOpenAction, allowPendingObsidianTestVaultOpenAction,
@@ -160,39 +164,194 @@ async function setConfiguredStateForLandingInspection(page: Page, configured: bo
}, configured); }, configured);
} }
async function captureDeclarativeMobileLanding(): Promise<string | undefined> { async function captureDeclarativeMobileSettings(): Promise<
| {
landingPage: string;
maintenance: string;
patches: string;
remoteConfiguration: string;
}
| undefined
> {
const port = obsidianRemoteDebuggingPort(); const port = obsidianRemoteDebuggingPort();
await setObsidianMobileTestMode(port, true, uiTimeoutMs); return await withObsidianPage(port, async (page) => {
try { const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
return await withObsidianPage(port, async (page) => { if (settingsNavigator.renderer !== "declarative") {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
if (settingsNavigator.renderer !== "declarative") {
await settingsNavigator.close();
return undefined;
}
await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
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(); await settingsNavigator.close();
return path; return undefined;
}
await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
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 };
}); });
} finally { if (remotePosition.top < 0 || remotePosition.bottom > remotePosition.viewportHeight) {
await setObsidianMobileTestMode(port, false, uiTimeoutMs); throw new Error("Remote Configuration was not visible at the top of the mobile settings landing page.");
} }
const remotePage = await settingsNavigator.openPage("Remote Configuration");
const e2eeHeading = remotePage
.locator("h4.sls-setting-panel-title")
.filter({ hasText: "E2EE Configuration" })
.first();
const e2eeActions = remotePage.locator(".setting-item").filter({
has: settingsNavigator.page.getByText("Configure E2EE", { exact: true }),
});
await e2eeHeading.waitFor({ state: "visible", timeout: uiTimeoutMs });
await e2eeActions.waitFor({ state: "visible", timeout: uiTimeoutMs });
const layoutFailures: string[] = [];
const actionLayout = await e2eeActions.evaluate((setting) => {
const control = setting.querySelector<HTMLElement>(".setting-item-control");
if (control === null) throw new Error("The E2EE action row did not contain a control group.");
const settingBounds = setting.getBoundingClientRect();
const buttonBounds = Array.from(control.querySelectorAll("button")).map((button) =>
button.getBoundingClientRect()
);
return {
controlClientWidth: control.clientWidth,
controlScrollWidth: control.scrollWidth,
rightmostButton: Math.max(...buttonBounds.map((bounds) => bounds.right)),
settingRight: settingBounds.right,
};
});
if (
actionLayout.controlScrollWidth > actionLayout.controlClientWidth + 1 ||
actionLayout.rightmostButton > actionLayout.settingRight + 1
) {
layoutFailures.push(`the E2EE actions overflowed their setting row (${JSON.stringify(actionLayout)})`);
}
await remotePage.evaluate((content) => {
content.scrollTop = content.scrollHeight - content.clientHeight;
content.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await settingsNavigator.page.waitForTimeout(50);
const panelLayout = await e2eeHeading.evaluate((heading) => {
const infoPanel = heading.parentElement?.querySelector<HTMLElement>(".info-panel");
if (infoPanel === null || infoPanel === undefined) {
throw new Error("The E2EE section did not contain its information panel.");
}
const headingBounds = heading.getBoundingClientRect();
const infoBounds = infoPanel.getBoundingClientRect();
return {
headingBottom: headingBounds.bottom,
headingPosition: getComputedStyle(heading).position,
headingTop: headingBounds.top,
infoBottom: infoBounds.bottom,
infoTop: infoBounds.top,
};
});
if (
panelLayout.headingBottom > panelLayout.infoTop + 1 &&
panelLayout.headingTop < panelLayout.infoBottom - 1
) {
layoutFailures.push(`the E2EE section heading overlapped its contents (${JSON.stringify(panelLayout)})`);
}
const remotePath = `${diagnosticsDirectory}/settings-declarative-remote-mobile.png`;
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: remotePath });
if (layoutFailures.length > 0) {
throw new Error(`The mobile Remote Configuration layout was invalid: ${layoutFailures.join("; ")}.`);
}
const maintenancePage = await settingsNavigator.openPage("Maintenance");
const markResolvedButton = maintenancePage
.locator(".op-warn button")
.filter({ hasText: "I've made a backup, mark this device 'resolved'" })
.first();
await markResolvedButton.evaluate((button) => {
const warning = button.closest<HTMLElement>(".op-warn");
if (warning === null) throw new Error("The Maintenance recovery action had no warning container.");
warning.removeClass("sls-setting-hidden");
});
await markResolvedButton.waitFor({ state: "visible", timeout: uiTimeoutMs });
await markResolvedButton.scrollIntoViewIfNeeded();
const maintenanceLayout = await markResolvedButton.evaluate((button) => {
const content = button.closest<HTMLElement>(".vertical-tab-content");
if (content === null) throw new Error("The Maintenance button was outside the settings content.");
const buttonBounds = button.getBoundingClientRect();
const contentBounds = content.getBoundingClientRect();
return {
buttonLeft: buttonBounds.left,
buttonRight: buttonBounds.right,
contentLeft: contentBounds.left,
contentRight: contentBounds.right,
rootClientWidth: document.documentElement.clientWidth,
rootScrollWidth: document.documentElement.scrollWidth,
};
});
const maintenancePath = `${diagnosticsDirectory}/settings-declarative-maintenance-mobile.png`;
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: maintenancePath });
if (
maintenanceLayout.buttonLeft < maintenanceLayout.contentLeft - 1 ||
maintenanceLayout.buttonRight > maintenanceLayout.contentRight + 1 ||
maintenanceLayout.rootScrollWidth > maintenanceLayout.rootClientWidth + 1
) {
layoutFailures.push(
`the Maintenance recovery action overflowed the settings pane (${JSON.stringify(maintenanceLayout)})`
);
}
const patchesPage = await settingsNavigator.openPage("Patches");
const remediationSetting = patchesPage.locator(".setting-item").filter({
has: settingsNavigator.page.locator('input[type="datetime-local"]'),
});
await remediationSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
await remediationSetting.scrollIntoViewIfNeeded();
const patchesLayout = await remediationSetting.evaluate((setting) => {
const content = setting.closest<HTMLElement>(".vertical-tab-content");
const control = setting.querySelector<HTMLElement>(".setting-item-control");
if (content === null || control === null) {
throw new Error("The Patches remediation row was incomplete.");
}
const applyButton = control.querySelector<HTMLElement>("button");
if (applyButton === null) throw new Error("The Patches remediation row did not contain Apply.");
const settingBounds = setting.getBoundingClientRect();
const contentBounds = content.getBoundingClientRect();
const buttonBounds = applyButton.getBoundingClientRect();
return {
buttonRight: buttonBounds.right,
contentRight: contentBounds.right,
controlClientWidth: control.clientWidth,
controlScrollWidth: control.scrollWidth,
rootClientWidth: document.documentElement.clientWidth,
rootScrollWidth: document.documentElement.scrollWidth,
settingRight: settingBounds.right,
};
});
const patchesPath = `${diagnosticsDirectory}/settings-declarative-patches-mobile.png`;
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: patchesPath });
if (
patchesLayout.buttonRight > patchesLayout.settingRight + 1 ||
patchesLayout.buttonRight > patchesLayout.contentRight + 1 ||
patchesLayout.controlScrollWidth > patchesLayout.controlClientWidth + 1 ||
patchesLayout.rootScrollWidth > patchesLayout.rootClientWidth + 1
) {
layoutFailures.push(
`the Patches remediation actions overflowed their setting row (${JSON.stringify(patchesLayout)})`
);
}
if (layoutFailures.length > 0) {
throw new Error(`The mobile settings layout was invalid: ${layoutFailures.join("; ")}.`);
}
await settingsNavigator.close();
return {
landingPage: path,
maintenance: maintenancePath,
patches: patchesPath,
remoteConfiguration: remotePath,
};
});
} }
async function openSettingsInitialisationDialogueForInspection(isP2P: boolean): Promise<void> { async function openSettingsInitialisationDialogueForInspection(isP2P: boolean): Promise<void> {
@@ -795,6 +954,72 @@ async function verifyPendingSettingsInitialisationFlow(): Promise<{ choice: stri
}); });
} }
function createSettingsPluginData(settingsOnlyRun: boolean): Record<string, unknown> {
return {
doctorProcessedVersion: settingsOnlyRun ? "1.0.0" : "0.25.27",
isConfigured: true,
liveSync: false,
versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncAfterMerge: false,
periodicReplication: false,
handleFilenameCaseSensitive: false,
useAdvancedMode: false,
usePowerUserMode: false,
useEdgeCaseMode: false,
};
}
async function captureDeclarativeMobileSettingsInFreshSession(
binary: string,
cliBinary: string
): Promise<
| {
landingPage: string;
maintenance: string;
patches: string;
remoteConfiguration: string;
}
| undefined
> {
// Enter mobile mode before LiveSync first loads so Obsidian fires the
// mobile settings-registration lifecycle used by a real mobile start-up.
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
...createSettingsPluginData(true),
useAdvancedMode: true,
useEdgeCaseMode: true,
usePowerUserMode: true,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
lifecycle: {
beforePluginStart: async ({ remoteDebuggingPort }) => {
await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs);
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await resumePendingCompatibilityReviewForSettings();
return await captureDeclarativeMobileSettings();
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
async function main(): Promise<void> { async function main(): Promise<void> {
const binary = requireObsidianBinary(); const binary = requireObsidianBinary();
const cli = discoverObsidianCli(); const cli = discoverObsidianCli();
@@ -804,29 +1029,14 @@ async function main(): Promise<void> {
const vault = await createTemporaryVault(); const vault = await createTemporaryVault();
await mkdir(diagnosticsDirectory, { recursive: true }); await mkdir(diagnosticsDirectory, { recursive: true });
let session: ObsidianLiveSyncSession | undefined; let session: ObsidianLiveSyncSession | undefined;
let settingsRenderer: "declarative" | "imperative" | undefined;
try { try {
session = await startObsidianLiveSyncSession({ session = await startObsidianLiveSyncSession({
binary, binary,
cliBinary: cli.binary, cliBinary: cli.binary,
vault, vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: { pluginData: createSettingsPluginData(settingsOnly),
doctorProcessedVersion: settingsOnly ? "1.0.0" : "0.25.27",
isConfigured: true,
liveSync: false,
versionUpFlash: settingsOnly ? "" : compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncAfterMerge: false,
periodicReplication: false,
handleFilenameCaseSensitive: false,
useAdvancedMode: false,
usePowerUserMode: false,
useEdgeCaseMode: false,
},
lifecycle: settingsOnly lifecycle: settingsOnly
? { ? {
afterLaunch: async ({ remoteDebuggingPort }) => { afterLaunch: async ({ remoteDebuggingPort }) => {
@@ -843,11 +1053,9 @@ async function main(): Promise<void> {
await verifyCompatibilityReview(); await verifyCompatibilityReview();
await verifyConfigDoctorFollowsCompatibilityReview(); await verifyConfigDoctorFollowsCompatibilityReview();
} }
const settingsRenderer = await verifyEffectiveSettings(); settingsRenderer = await verifyEffectiveSettings();
const initialisation = await verifyPendingSettingsInitialisationFlow(); const initialisation = await verifyPendingSettingsInitialisationFlow();
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue(); const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
const mobileLanding = settingsRenderer === "declarative" ? await captureDeclarativeMobileLanding() : undefined;
if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`);
console.log( console.log(
`Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}` `Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}`
); );
@@ -858,6 +1066,17 @@ async function main(): Promise<void> {
} }
await vault.dispose(); await vault.dispose();
} }
const mobileSettings =
settingsRenderer === "declarative"
? await captureDeclarativeMobileSettingsInFreshSession(binary, cli.binary)
: undefined;
if (mobileSettings) {
console.log(`Declarative mobile settings landing page: ${mobileSettings.landingPage}`);
console.log(`Declarative mobile Remote Configuration page: ${mobileSettings.remoteConfiguration}`);
console.log(`Declarative mobile Maintenance page: ${mobileSettings.maintenance}`);
console.log(`Declarative mobile Patches page: ${mobileSettings.patches}`);
}
} }
main().catch((error: unknown) => { main().catch((error: unknown) => {
+12 -16
View File
@@ -12,6 +12,18 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased ## Unreleased
## 1.0.21
26th August, 2026
It is becoming more 'ordinary' with each release, but please let me know if anything has become less convenient.
### Interface and translation
#### Fixed
- Remote Configuration section headings no longer overlap their contents when scrolling on mobile. Action buttons in Remote Configuration, Maintenance, and Patches now remain inside the settings pane on narrow screens.
## 1.0.20 ## 1.0.20
~~1.0.19~~ was cancelled because prerelease validation exposed an incorrect warning at start-up. ~~1.0.19~~ was cancelled because prerelease validation exposed an incorrect warning at start-up.
@@ -81,19 +93,3 @@ I know this is the second time I have said it, but I had grown quite fond of the
- One-shot CouchDB synchronisation now releases stalled web-compatible connection checks before replication starts, so a later synchronisation can make a fresh attempt (Commonlib 0.1.16). - One-shot CouchDB synchronisation now releases stalled web-compatible connection checks before replication starts, so a later synchronisation can make a fresh attempt (Commonlib 0.1.16).
- The 60-second safeguard applies only to pre-replication checks. It does not limit ordinary synchronisation, and the **Use Internal API** path is unchanged. - The 60-second safeguard applies only to pre-replication checks. It does not limit ordinary synchronisation, and the **Use Internal API** path is unchanged.
## 1.0.15
15th August, 2026
### Synchronisation and storage
#### Improved
- Start-up offline scanning is now faster, especially for larger Vaults using path obfuscation (Commonlib 0.1.15).
### Interface and translation
#### Improved
- The Traditional Chinese translation catalogue has been completed and polished for broader coverage and more natural, consistent terminology (PR #1106). Thank you to @nimula for the contribution!
+2 -1
View File
@@ -32,5 +32,6 @@
"1.0.17": "1.7.2", "1.0.17": "1.7.2",
"1.0.18": "1.7.2", "1.0.18": "1.7.2",
"1.0.19": "1.7.2", "1.0.19": "1.7.2",
"1.0.20": "1.7.2" "1.0.20": "1.7.2",
"1.0.21": "1.7.2"
} }