Narrow service and command composition

This commit is contained in:
vorotamoroz committed 2026-10-29 15:00:19 +00:00
1 parent 273079e827
commit 4027417bf5
22 files changed
+928 -202

No files matched your search

@@ -14,7 +14,7 @@ import {
statusDisplay,
type ConfigurationItem,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import {
type AllSettingItemKey,
getConfig,
@@ -24,7 +24,8 @@ import {
type AllBooleanItemKey,
} from "./settingConstants.ts";
import { $msg } from "@/common/translation";
import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
import type { AutoWireOption, OnUpdateResult } from "./SettingPane.ts";
import { wrapMemo } from "octagonal-wheels/function";
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
export class LiveSyncSetting extends Setting {
@@ -109,9 +110,13 @@ export class LiveSyncSetting extends Setting {
}
LiveSyncSetting.env.requestUpdate();
}
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
private autoWireStringComponent<TComponent extends TextComponent | TextAreaComponent>(
key: AllStringItemKey,
addComponent: (callback: (component: TComponent) => void) => void,
opt?: AutoWireOption
) {
const conf = this.autoWireSetting(key, opt);
this.addText((text) => {
addComponent((text) => {
this.autoWiredComponent = text;
const setValue = wrapMemo((value: string) => {
text.setValue(value);
@@ -127,26 +132,25 @@ export class LiveSyncSetting extends Setting {
}
this.autoWireComponent(this.autoWiredComponent, conf, opt);
});
}
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
this.autoWireStringComponent<TextComponent>(
key,
(callback) => {
this.addText(callback);
},
opt
);
return this;
}
autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption) {
const conf = this.autoWireSetting(key, opt);
this.addTextArea((text) => {
this.autoWiredComponent = text;
const setValue = wrapMemo((value: string) => {
text.setValue(value);
});
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
this.invalidateValue();
text.onChange(async (value) => {
await this.commitValue(value);
});
if (opt?.isPassword) {
text.inputEl.setAttribute("type", "password");
this.hasPassword = true;
}
this.autoWireComponent(this.autoWiredComponent, conf, opt);
});
this.autoWireStringComponent<TextAreaComponent>(
key,
(callback) => {
this.addTextArea(callback);
},
opt
);
return this;
}
autoWireNumeric(
@@ -0,0 +1,227 @@
import { describe, expect, it, vi } from "vitest";
const liveSyncSettingHarness = vi.hoisted(() => ({
components: [] as unknown[],
settingElements: [] as unknown[],
}));
vi.mock("@/deps.ts", () => {
class StringComponent {
values: string[] = [];
changeHandler?: (value: string) => Promise<void>;
placeholder?: string;
inputEl = {
attributes: new Map<string, string>(),
setAttribute(name: string, value: string) {
this.attributes.set(name, value);
},
toggleClass: (_name: string, _enabled: boolean) => {},
};
setValue(value: string) {
this.values.push(value);
}
onChange(handler: (value: string) => Promise<void>) {
this.changeHandler = handler;
return this;
}
setPlaceholder(value: string) {
this.placeholder = value;
return this;
}
constructor() {
liveSyncSettingHarness.components.push(this);
}
}
class TextComponent extends StringComponent {}
class TextAreaComponent extends StringComponent {}
class Setting {
settingEl = {
classes: [] as { name: string; enabled: boolean }[],
toggleClass(name: string, enabled: boolean) {
this.classes.push({ name, enabled });
},
};
constructor(_containerEl: HTMLElement) {
liveSyncSettingHarness.settingElements.push(this.settingEl);
}
setDesc(_description: string | DocumentFragment) {
return this;
}
setName(_name: string | DocumentFragment) {
return this;
}
addText(callback: (component: TextComponent) => void) {
callback(new TextComponent());
return this;
}
addTextArea(callback: (component: TextAreaComponent) => void) {
callback(new TextAreaComponent());
return this;
}
setTooltip(_description: string) {
return this;
}
setDisabled(_disabled: boolean) {
return this;
}
}
return { Setting, TextComponent, TextAreaComponent, ButtonComponent: class {} };
});
vi.mock("./ObsidianLiveSyncSettingTab.ts", () => ({}));
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
LEVEL_ADVANCED: 1,
LEVEL_POWER_USER: 2,
statusDisplay: () => "",
}));
vi.mock("./settingConstants.ts", () => ({
getConfig: (key: string) => ({
name: key,
desc: `description for ${key}`,
placeHolder: `configured placeholder for ${key}`,
}),
}));
vi.mock("./settingComponentStyles.ts", () => ({ setButtonDestructiveState: vi.fn() }));
vi.mock("octagonal-wheels/collection", () => ({ unique: (items: unknown[]) => [...new Set(items)] }));
import { LiveSyncSetting } from "./LiveSyncSetting.ts";
type WireKind = "text" | "textarea";
type StringComponentHarness = {
values: string[];
changeHandler?: (value: string) => Promise<void>;
placeholder?: string;
inputEl: { attributes: Map<string, string> };
};
type SettingElementHarness = { classes: { name: string; enabled: boolean }[] };
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function wireStringSetting(
kind: WireKind,
options: {
holdValue?: boolean;
isPassword?: boolean;
placeHolder?: string;
onUpdate?: () => { visibility?: boolean };
} = {}
) {
const key = kind === "text" ? "syncOnlyRegEx" : "ignoreFiles";
const events: string[] = [];
const editingSettings: Record<string, unknown> = { [key]: 17 };
const saveSettings = vi.fn(async (_keys: string[]) => {});
const requestUpdate = vi.fn(() => events.push("update"));
const env = {
settingComponents: [] as unknown[],
editingSettings,
saveSettings,
requestUpdate,
};
LiveSyncSetting.env = env as never;
const setting = new LiveSyncSetting({} as HTMLElement);
const onUpdate = vi.fn(options.onUpdate ?? (() => ({ visibility: false })));
const wireOptions = { ...options, onUpdate };
if (kind === "text") {
setting.autoWireText(key as never, wireOptions);
} else {
setting.autoWireTextArea(key as never, wireOptions);
}
return {
key,
events,
editingSettings,
saveSettings,
requestUpdate,
setting,
component: setting.autoWiredComponent as unknown as StringComponentHarness,
settingElement: setting.settingEl as unknown as SettingElementHarness,
onUpdate,
};
}
describe("LiveSyncSetting string controls", () => {
it.each(["text", "textarea"] as const)("wires %s through the shared string commit contract", async (kind) => {
const {
key,
events,
editingSettings,
saveSettings,
requestUpdate,
setting,
component,
settingElement,
onUpdate,
} = wireStringSetting(kind, { isPassword: true, placeHolder: "option placeholder" });
expect(component.values).toEqual(["17"]);
setting.invalidateValue?.();
expect(component.values).toEqual(["17"]);
editingSettings[key] = 18;
setting.invalidateValue?.();
expect(component.values).toEqual(["17", "18"]);
expect(component.inputEl.attributes.get("type")).toBe("password");
expect(setting.hasPassword).toBe(true);
expect(setting.updateHandlers.size).toBe(1);
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(setting._getComputedStatus()).toEqual({ visibility: false });
expect(onUpdate).toHaveBeenCalledTimes(2);
expect(settingElement.classes).toContainEqual({ name: "sls-setting-hidden", enabled: true });
if (kind === "text") {
expect(component.placeholder).toBe("configured placeholder for syncOnlyRegEx");
} else {
expect(component.placeholder).toBeUndefined();
}
const pendingSave = deferred();
saveSettings.mockImplementationOnce(async (keys) => {
events.push(`save:${keys.join(",")}`);
await pendingSave.promise;
events.push("saved");
});
const commit = component.changeHandler!("changed");
expect(editingSettings[key]).toBe("changed");
expect(saveSettings).toHaveBeenCalledWith([key]);
expect(events).toEqual([`save:${key}`]);
expect(requestUpdate).not.toHaveBeenCalled();
pendingSave.resolve();
await commit;
expect(events).toEqual([`save:${key}`, "saved", "update"]);
events.length = 0;
await component.changeHandler!("changed");
expect(saveSettings).toHaveBeenCalledTimes(1);
expect(events).toEqual(["update"]);
events.length = 0;
const failure = new Error("setting save failed");
saveSettings.mockImplementationOnce(async (keys) => {
events.push(`save:${keys.join(",")}`);
throw failure;
});
await expect(component.changeHandler!("failed")).rejects.toBe(failure);
expect(editingSettings[key]).toBe("failed");
expect(events).toEqual([`save:${key}`]);
expect(requestUpdate).toHaveBeenCalledTimes(2);
});
it.each(["text", "textarea"] as const)("keeps %s held values in the editing copy without saving", async (kind) => {
const { key, editingSettings, saveSettings, requestUpdate, component, events } = wireStringSetting(kind, {
holdValue: true,
});
await component.changeHandler!("held value");
expect(editingSettings[key]).toBe("held value");
expect(saveSettings).not.toHaveBeenCalled();
expect(requestUpdate).toHaveBeenCalledOnce();
expect(events).toEqual(["update"]);
});
});
@@ -1,4 +1,8 @@
import { LEVEL_ADVANCED, type CustomRegExpSource } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
LEVEL_ADVANCED,
type CustomRegExpSource,
type CustomRegExpSourceList,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { constructCustomRegExpList, splitCustomRegExpList } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import MultipleRegExpControl from "./MultipleRegExpControl.svelte";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -7,6 +11,35 @@ import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts
import type { PageFunctions } from "./SettingPane.ts";
import { visibleOnly } from "./SettingPane.ts";
import { $msg } from "@/common/translation";
type RegExpControlOwner = Pick<
ObsidianLiveSyncSettingTab,
"saveAllDirtySettings" | "requestPageRefresh" | "lifetimeComponent"
>;
function mountMultipleRegExpControl<D extends "|[]|" | ",">(
owner: RegExpControlOwner,
target: HTMLElement,
source: CustomRegExpSourceList<D>,
delimiter: D,
assign: (serializedPatterns: CustomRegExpSourceList<D>) => void
): void {
const patterns = splitCustomRegExpList(source, delimiter);
const control = mount(MultipleRegExpControl, {
target,
props: {
patterns,
originals: [...patterns],
apply: async (newPatterns: CustomRegExpSource[]) => {
assign(constructCustomRegExpList(newPatterns, delimiter));
await owner.saveAllDirtySettings();
owner.requestPageRefresh();
},
},
});
owner.lifetimeComponent.register(() => void unmount(control));
}
export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
void addPanel(paneEl, $msg("Normal Files")).then((paneEl) => {
const syncFilesSetting = new Setting(paneEl)
@@ -16,37 +49,29 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
)
);
const syncFilesControl = mount(MultipleRegExpControl, {
target: syncFilesSetting.controlEl,
props: {
patterns: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"),
originals: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"),
apply: async (newPatterns: CustomRegExpSource[]) => {
this.editingSettings.syncOnlyRegEx = constructCustomRegExpList(newPatterns, "|[]|");
await this.saveAllDirtySettings();
this.requestPageRefresh();
},
},
});
this.lifetimeComponent.register(() => void unmount(syncFilesControl));
mountMultipleRegExpControl(
this,
syncFilesSetting.controlEl,
this.editingSettings.syncOnlyRegEx,
"|[]|",
(value) => {
this.editingSettings.syncOnlyRegEx = value;
}
);
const nonSyncFilesSetting = new Setting(paneEl)
.setName($msg("Non-Synchronising files"))
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
const nonSyncFilesControl = mount(MultipleRegExpControl, {
target: nonSyncFilesSetting.controlEl,
props: {
patterns: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"),
originals: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"),
apply: async (newPatterns: CustomRegExpSource[]) => {
this.editingSettings.syncIgnoreRegEx = constructCustomRegExpList(newPatterns, "|[]|");
await this.saveAllDirtySettings();
this.requestPageRefresh();
},
},
});
this.lifetimeComponent.register(() => void unmount(nonSyncFilesControl));
mountMultipleRegExpControl(
this,
nonSyncFilesSetting.controlEl,
this.editingSettings.syncIgnoreRegEx,
"|[]|",
(value) => {
this.editingSettings.syncIgnoreRegEx = value;
}
);
new Setting(paneEl).autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 });
new Setting(paneEl).autoWireToggle("useIgnoreFiles");
@@ -58,41 +83,30 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
const targetPatternSetting = new Setting(paneEl)
.setName($msg("Target patterns"))
.setDesc($msg("Patterns to match files for syncing"));
const patTarget = splitCustomRegExpList(this.editingSettings.syncInternalFilesTargetPatterns, ",");
const targetPatternControl = mount(MultipleRegExpControl, {
target: targetPatternSetting.controlEl,
props: {
patterns: patTarget,
originals: [...patTarget],
apply: async (newPatterns: CustomRegExpSource[]) => {
this.editingSettings.syncInternalFilesTargetPatterns = constructCustomRegExpList(newPatterns, ",");
await this.saveAllDirtySettings();
this.requestPageRefresh();
},
},
});
this.lifetimeComponent.register(() => void unmount(targetPatternControl));
mountMultipleRegExpControl(
this,
targetPatternSetting.controlEl,
this.editingSettings.syncInternalFilesTargetPatterns,
",",
(value) => {
this.editingSettings.syncInternalFilesTargetPatterns = value;
}
);
const defaultSkipPattern = "\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/";
const defaultSkipPatternXPlat =
defaultSkipPattern + ",\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$";
const pat = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
const patSetting = new Setting(paneEl).setName($msg("Ignore patterns")).setDesc("");
const ignorePatternControl = mount(MultipleRegExpControl, {
target: patSetting.controlEl,
props: {
patterns: pat,
originals: [...pat],
apply: async (newPatterns: CustomRegExpSource[]) => {
this.editingSettings.syncInternalFilesIgnorePatterns = constructCustomRegExpList(newPatterns, ",");
await this.saveAllDirtySettings();
this.requestPageRefresh();
},
},
});
this.lifetimeComponent.register(() => void unmount(ignorePatternControl));
mountMultipleRegExpControl(
this,
patSetting.controlEl,
this.editingSettings.syncInternalFilesIgnorePatterns,
",",
(value) => {
this.editingSettings.syncInternalFilesIgnorePatterns = value;
}
);
const addDefaultPatterns = async (patterns: string) => {
const oldList = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
@@ -122,22 +136,14 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
const overwritePatterns = new Setting(paneEl)
.setName($msg("Overwrite patterns"))
.setDesc($msg("Patterns to match files for overwriting instead of merging"));
const patTarget2 = splitCustomRegExpList(this.editingSettings.syncInternalFileOverwritePatterns, ",");
const overwritePatternControl = mount(MultipleRegExpControl, {
target: overwritePatterns.controlEl,
props: {
patterns: patTarget2,
originals: [...patTarget2],
apply: async (newPatterns: CustomRegExpSource[]) => {
this.editingSettings.syncInternalFileOverwritePatterns = constructCustomRegExpList(
newPatterns,
","
);
await this.saveAllDirtySettings();
this.requestPageRefresh();
},
},
});
this.lifetimeComponent.register(() => void unmount(overwritePatternControl));
mountMultipleRegExpControl(
this,
overwritePatterns.controlEl,
this.editingSettings.syncInternalFileOverwritePatterns,
",",
(value) => {
this.editingSettings.syncInternalFileOverwritePatterns = value;
}
);
});
}
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const paneSelectorHarness = vi.hoisted(() => ({
mounts: [] as { component: unknown; target: unknown; props: Record<string, unknown>; handle: unknown }[],
unmounted: [] as unknown[],
settings: [] as { containerEl: HTMLElement; controlEl: unknown }[],
buttons: [] as { text: string; handler?: () => void | Promise<void> }[],
}));
vi.mock("@/common/translation", () => ({ $msg: (value: string) => value }));
vi.mock("./MultipleRegExpControl.svelte", () => ({ default: { name: "MultipleRegExpControl" } }));
vi.mock("svelte", () => ({
mount: vi.fn((component: unknown, options: { target: unknown; props: Record<string, unknown> }) => {
const handle = { id: paneSelectorHarness.mounts.length };
paneSelectorHarness.mounts.push({ component, target: options.target, props: options.props, handle });
return handle;
}),
unmount: vi.fn((component: unknown) => {
paneSelectorHarness.unmounted.push(component);
}),
}));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class {
controlEl: { id: number };
constructor(containerEl: HTMLElement) {
this.controlEl = { id: paneSelectorHarness.settings.length };
paneSelectorHarness.settings.push({ containerEl, controlEl: this.controlEl });
}
setName(_name: string) {
return this;
}
setDesc(_description: string) {
return this;
}
autoWireNumeric(_key: string, _options: unknown) {
return this;
}
autoWireToggle(_key: string) {
return this;
}
autoWireTextArea(_key: string, _options: unknown) {
return this;
}
addButton(
callback: (button: {
setButtonText: (text: string) => unknown;
onClick: (handler: () => void | Promise<void>) => unknown;
}) => void
) {
const button = {
text: "",
handler: undefined as (() => void | Promise<void>) | undefined,
setButtonText(text: string) {
this.text = text;
return this;
},
onClick(handler: () => void | Promise<void>) {
this.handler = handler;
paneSelectorHarness.buttons.push(this);
return this;
},
};
callback(button);
return this;
}
},
}));
import { paneSelector } from "./PaneSelector.ts";
type PatternControlProps = {
patterns: string[];
originals: string[];
apply: (patterns: string[]) => Promise<void>;
};
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function renderSelector() {
const normalPanel = { id: "normal" } as unknown as HTMLElement;
const hiddenPanel = { id: "hidden" } as unknown as HTMLElement;
const addPanel = vi.fn((_parent: HTMLElement, title: string) =>
Promise.resolve(title === "Normal Files" ? normalPanel : hiddenPanel)
);
const disposers: (() => void)[] = [];
const tab = {
editingSettings: {
syncOnlyRegEx: "alpha|[]|beta",
syncIgnoreRegEx: "skip|[]|also-skip",
syncInternalFilesTargetPatterns: "target-a,target-b",
syncInternalFilesIgnorePatterns: "existing,\\/node_modules\\/",
syncInternalFileOverwritePatterns: "overwrite-a,overwrite-b",
},
saveAllDirtySettings: vi.fn(async () => {}),
requestPageRefresh: vi.fn(),
lifetimeComponent: { register: (dispose: () => void) => disposers.push(dispose) },
};
paneSelector.call(tab as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(paneSelectorHarness.mounts).toHaveLength(5));
return { tab, addPanel, disposers };
}
function controlProps(index: number): PatternControlProps {
return paneSelectorHarness.mounts[index]!.props as unknown as PatternControlProps;
}
beforeEach(() => {
paneSelectorHarness.mounts.length = 0;
paneSelectorHarness.unmounted.length = 0;
paneSelectorHarness.settings.length = 0;
paneSelectorHarness.buttons.length = 0;
vi.clearAllMocks();
});
describe("paneSelector multiple-pattern controls", () => {
it("keeps all five bindings, independent originals, delimiter choices, save ordering, and owned cleanup", async () => {
const { tab, addPanel, disposers } = await renderSelector();
expect(addPanel.mock.calls.map(([, title]) => title)).toEqual(["Normal Files", "Hidden Files"]);
expect(paneSelectorHarness.mounts.map(({ props }) => (props as PatternControlProps).patterns)).toEqual([
["alpha", "beta"],
["skip", "also-skip"],
["target-a", "target-b"],
["existing", "\\/node_modules\\/"],
["overwrite-a", "overwrite-b"],
]);
for (let index = 0; index < paneSelectorHarness.mounts.length; index++) {
const { patterns, originals } = controlProps(index);
expect(originals).toEqual(patterns);
expect(originals).not.toBe(patterns);
patterns.push("edited-current-value");
expect(originals).not.toContain("edited-current-value");
}
const cases = [
{
index: 0,
key: "syncOnlyRegEx",
patterns: ["new-normal-a", "new-normal-b"],
value: "new-normal-a|[]|new-normal-b",
},
{
index: 1,
key: "syncIgnoreRegEx",
patterns: ["new-skip-a", "new-skip-b"],
value: "new-skip-a|[]|new-skip-b",
},
{
index: 2,
key: "syncInternalFilesTargetPatterns",
patterns: ["new-target-a", "new-target-b"],
value: "new-target-a,new-target-b",
},
{
index: 3,
key: "syncInternalFilesIgnorePatterns",
patterns: ["new-ignore-a", "new-ignore-b"],
value: "new-ignore-a,new-ignore-b",
},
{
index: 4,
key: "syncInternalFileOverwritePatterns",
patterns: ["new-overwrite-a", "new-overwrite-b"],
value: "new-overwrite-a,new-overwrite-b",
},
] as const;
const events: string[] = [];
for (let index = 0; index < cases.length; index++) {
const item = cases[index]!;
const pendingSave = deferred();
events.length = 0;
tab.saveAllDirtySettings.mockImplementationOnce(async () => {
events.push(`save:${item.key}`);
await pendingSave.promise;
events.push("saved");
});
tab.requestPageRefresh.mockImplementationOnce(() => {
events.push("refresh");
});
const applying = controlProps(item.index).apply([...item.patterns]);
expect(tab.editingSettings[item.key as keyof typeof tab.editingSettings]).toBe(item.value);
expect(tab.saveAllDirtySettings).toHaveBeenCalledTimes(index + 1);
expect(events).toEqual([`save:${item.key}`]);
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(index);
pendingSave.resolve();
await applying;
expect(events).toEqual([`save:${item.key}`, "saved", "refresh"]);
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(index + 1);
}
const failure = new Error("settings save failed");
tab.saveAllDirtySettings.mockRejectedValueOnce(failure);
await expect(controlProps(2).apply(["assigned-before-failure"])).rejects.toBe(failure);
expect(tab.editingSettings.syncInternalFilesTargetPatterns).toBe("assigned-before-failure");
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(cases.length);
expect(disposers).toHaveLength(5);
disposers.forEach((dispose) => dispose());
expect(paneSelectorHarness.unmounted).toEqual(paneSelectorHarness.mounts.map(({ handle }) => handle));
});
it("merges default hidden-file ignores with existing patterns and removes duplicates", async () => {
const { tab } = await renderSelector();
const defaultButton = paneSelectorHarness.buttons.find(({ text }) => text === "Default");
const crossPlatformButton = paneSelectorHarness.buttons.find(({ text }) => text === "Cross-platform");
expect(defaultButton?.handler).toBeDefined();
expect(crossPlatformButton?.handler).toBeDefined();
await defaultButton!.handler!();
expect(tab.editingSettings.syncInternalFilesIgnorePatterns).toBe(
"existing,\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/"
);
await crossPlatformButton!.handler!();
expect(tab.editingSettings.syncInternalFilesIgnorePatterns).toBe(
"existing,\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/,\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$"
);
expect(tab.saveAllDirtySettings).toHaveBeenCalledTimes(2);
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(2);
});
});
@@ -97,16 +97,6 @@ export function findAttrFromParent(el: HTMLElement, attr: string): string {
return "";
}
export function wrapMemo<T>(func: (arg: T) => void) {
let buf: T | undefined = undefined;
return (arg: T) => {
if (buf !== arg) {
func(arg);
buf = arg;
}
};
}
/**
* Defers pane construction until the owning settings page can confirm that its
* render scope is still active.