mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 14:58:18 +00:00
Narrow service and command composition
This commit is contained in:
1 parent
273079e827
commit
4027417bf5
22 files changed
+928
-202
No files matched your search
@@ -31,6 +31,7 @@ import { useReplicationScheduling, type ReplicationSchedulingControl } from "./s
|
||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
@@ -290,7 +291,11 @@ export class LiveSyncBaseCore<
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useConflictResolutionFeature(this);
|
||||
useConflictResolutionFeature({
|
||||
services: this.services,
|
||||
serviceModules: this.serviceModules,
|
||||
log: createInstanceLogFunction("SF:ConflictResolution", this.services.API),
|
||||
});
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCommandCheck } from "@vrtmrz/obsidian-plugin-kit/command";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
@@ -69,14 +70,12 @@ export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
services.API.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.vault.getActiveFilePath(),
|
||||
(file) => {
|
||||
fireAndForget(() => copyFileDatabaseInfo(createFileDatabaseInfoCore(host), file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
@@ -115,13 +114,12 @@ export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
services.API.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.setting.currentSettings().useAdvancedMode,
|
||||
() => {
|
||||
fireAndForget(() => services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
@@ -135,13 +133,12 @@ export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
services.API.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.setting.currentSettings().useAdvancedMode,
|
||||
() => {
|
||||
fireAndForget(() => services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
|
||||
@@ -228,6 +228,49 @@ describe("useBasicCommandsFeature", () => {
|
||||
expect(adapter).not.toBe(fixture.host);
|
||||
});
|
||||
|
||||
it("rechecks availability on execution and captures the current file only once", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
vi.mocked(copyFileDatabaseInfo).mockClear();
|
||||
const dump = fixture.getCommand("livesync-dump");
|
||||
expect(dump.checkCallback?.(true)).toBe(true);
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(undefined);
|
||||
expect(dump.checkCallback?.(false)).toBe(false);
|
||||
expect(copyFileDatabaseInfo).not.toHaveBeenCalled();
|
||||
|
||||
fixture.services.vault.getActiveFilePath
|
||||
.mockReset()
|
||||
.mockReturnValueOnce("selected.md")
|
||||
.mockReturnValue("later.md");
|
||||
expect(dump.checkCallback?.(false)).toBe(true);
|
||||
await vi.waitFor(() => expect(copyFileDatabaseInfo).toHaveBeenCalledOnce());
|
||||
expect(fixture.services.vault.getActiveFilePath).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(copyFileDatabaseInfo).mock.calls[0][1]).toBe("selected.md");
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
for (const id of ["livesync-scan-files", "livesync-abortsync"]) {
|
||||
expect(fixture.getCommand(id).checkCallback?.(true)).toBe(true);
|
||||
}
|
||||
fixture.settings.useAdvancedMode = false;
|
||||
for (const id of ["livesync-scan-files", "livesync-abortsync"]) {
|
||||
expect(fixture.getCommand(id).checkCallback?.(false)).toBe(false);
|
||||
}
|
||||
expect(fixture.services.vault.scanVault).not.toHaveBeenCalled();
|
||||
expect(fixture.services.replication.stopActiveTransfer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates an availability failure without running the command", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
vi.mocked(copyFileDatabaseInfo).mockClear();
|
||||
const failure = new Error("Vault unavailable");
|
||||
fixture.services.vault.getActiveFilePath.mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
expect(() => fixture.getCommand("livesync-dump").checkCallback?.(false)).toThrow(failure);
|
||||
expect(copyFileDatabaseInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits pending file events from the batch command", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import type {
|
||||
NecessaryServices,
|
||||
NecessaryServicesInterfaces,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
@@ -24,6 +27,16 @@ export interface CompatibilityReviewUi {
|
||||
clearReminder(): void;
|
||||
}
|
||||
|
||||
export type CompatibilityReviewDependencies = NecessaryServicesInterfaces<
|
||||
"setting" | "config" | "vault" | "control",
|
||||
never
|
||||
>;
|
||||
|
||||
export type CompatibilityReviewHost = NecessaryServices<
|
||||
"setting" | "config" | "vault" | "control" | "appLifecycle" | "API",
|
||||
never
|
||||
>;
|
||||
|
||||
export class CompatibilityReviewController {
|
||||
private pause: CompatibilityPause | undefined;
|
||||
private activeReview: Promise<void> | undefined;
|
||||
@@ -31,7 +44,7 @@ export class CompatibilityReviewController {
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly core: LiveSyncCore,
|
||||
private readonly core: CompatibilityReviewDependencies,
|
||||
private readonly ui: CompatibilityReviewUi,
|
||||
private readonly currentVersion: number = VER
|
||||
) {}
|
||||
@@ -161,7 +174,10 @@ export class CompatibilityReviewController {
|
||||
}
|
||||
}
|
||||
|
||||
export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityReviewUi): CompatibilityReviewController {
|
||||
export function useCompatibilityReview(
|
||||
core: CompatibilityReviewHost,
|
||||
ui: CompatibilityReviewUi
|
||||
): CompatibilityReviewController {
|
||||
const controller = new CompatibilityReviewController(core, ui);
|
||||
core.services.appLifecycle.onSettingLoaded.addHandler(() => controller.initialise());
|
||||
core.services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import type {
|
||||
NecessaryServices,
|
||||
NecessaryServicesInterfaces,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
@@ -101,6 +105,32 @@ function createFixture(
|
||||
}
|
||||
|
||||
describe("compatibility review controller", () => {
|
||||
it("accepts operation services without retaining an application-core contract", () => {
|
||||
type Dependencies = NecessaryServicesInterfaces<"setting" | "config" | "vault" | "control", never>;
|
||||
type ControllerHost = ConstructorParameters<typeof CompatibilityReviewController>[0];
|
||||
|
||||
expectTypeOf<Dependencies>().toExtend<ControllerHost>();
|
||||
expectTypeOf<keyof ControllerHost>().toEqualTypeOf<"services" | "serviceModules">();
|
||||
expectTypeOf<keyof ControllerHost["services"]>().toEqualTypeOf<"setting" | "config" | "vault" | "control">();
|
||||
expectTypeOf<keyof ControllerHost["serviceModules"]>().toBeNever();
|
||||
});
|
||||
|
||||
it("composes the review from its operation services and lifecycle and command ports", () => {
|
||||
type Dependencies = NecessaryServices<
|
||||
"setting" | "config" | "vault" | "control" | "appLifecycle" | "API",
|
||||
never
|
||||
>;
|
||||
type FeatureHost = Parameters<typeof useCompatibilityReview>[0];
|
||||
|
||||
expectTypeOf<Dependencies>().toExtend<FeatureHost>();
|
||||
expectTypeOf<keyof FeatureHost>().toEqualTypeOf<"services" | "serviceModules">();
|
||||
expectTypeOf<keyof FeatureHost["services"]>().toEqualTypeOf<
|
||||
"context" | "setting" | "config" | "vault" | "control" | "appLifecycle" | "API"
|
||||
>();
|
||||
expectTypeOf<keyof FeatureHost["serviceModules"]>().toBeNever();
|
||||
expectTypeOf<ReturnType<typeof useCompatibilityReview>>().toEqualTypeOf<CompatibilityReviewController>();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import {
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import {
|
||||
createInstanceLogFunction,
|
||||
MARK_LOG_SEPARATOR,
|
||||
type LogFunction,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { ConflictResolutionHost } from "./index";
|
||||
import { createConflictResolutionOperations, useConflictResolutionFeature } from "./index";
|
||||
import type { ConflictResolutionOperationsDependencies } from "./operations";
|
||||
@@ -64,13 +70,15 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
vault: { getActiveFilePath: activeFile },
|
||||
};
|
||||
const serviceModules = { databaseFileAccess, fileHandler, storageAccess };
|
||||
const log = createInstanceLogFunction("SF:ConflictResolution", services.API as unknown as IAPIService);
|
||||
|
||||
if (options.compose !== false) {
|
||||
useConflictResolutionFeature({ services, serviceModules } as unknown as ConflictResolutionHost);
|
||||
useConflictResolutionFeature({ services, serviceModules, log } as unknown as ConflictResolutionHost);
|
||||
}
|
||||
|
||||
return {
|
||||
addLog,
|
||||
log,
|
||||
conflict,
|
||||
context,
|
||||
databaseFileAccess,
|
||||
@@ -151,6 +159,87 @@ function createOperationsHarness() {
|
||||
}
|
||||
|
||||
describe("conflict resolution serviceFeature", () => {
|
||||
it("declares a borrowed logger and only the services used by conflict resolution", () => {
|
||||
expectTypeOf<keyof ConflictResolutionHost["services"]>().toEqualTypeOf<
|
||||
"context" | "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault"
|
||||
>();
|
||||
expectTypeOf<ConflictResolutionHost["log"]>().toEqualTypeOf<LogFunction>();
|
||||
});
|
||||
|
||||
it("uses the injected logger for the checker and resolver without an API service", async () => {
|
||||
const harness = createHarness({
|
||||
compose: false,
|
||||
settings: { checkConflictOnlyOnOpen: true },
|
||||
activeFile: "active.md" as FilePathWithPrefix,
|
||||
});
|
||||
const { API, ...services } = harness.services;
|
||||
const log = vi.fn<LogFunction>();
|
||||
useConflictResolutionFeature({
|
||||
services,
|
||||
serviceModules: harness.serviceModules,
|
||||
log,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
|
||||
await harness.conflict.queueCheckForIfOpen("other.md" as FilePathWithPrefix);
|
||||
await harness.conflict.resolveByNewest("missing.md" as FilePathWithPrefix);
|
||||
|
||||
expect(log).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"other.md is conflicted, merging process has been postponed.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
expect(log).toHaveBeenNthCalledWith(2, "Could not get current revision of missing.md");
|
||||
expect(API.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps separately selected loggers isolated when legacy API services are shared", async () => {
|
||||
const first = createHarness({ compose: false });
|
||||
const second = createHarness({ compose: false });
|
||||
const firstLog = vi.fn<LogFunction>();
|
||||
const secondLog = vi.fn<LogFunction>();
|
||||
const sharedAPI = first.services.API;
|
||||
useConflictResolutionFeature({
|
||||
services: first.services,
|
||||
serviceModules: first.serviceModules,
|
||||
log: firstLog,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
useConflictResolutionFeature({
|
||||
services: { ...second.services, API: sharedAPI },
|
||||
serviceModules: second.serviceModules,
|
||||
log: secondLog,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
first.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
second.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
|
||||
await first.conflict.resolveByNewest("first.md" as FilePathWithPrefix);
|
||||
await second.conflict.resolveByNewest("second.md" as FilePathWithPrefix);
|
||||
|
||||
expect(firstLog).toHaveBeenCalledExactlyOnceWith("Could not get current revision of first.md");
|
||||
expect(secondLog).toHaveBeenCalledExactlyOnceWith("Could not get current revision of second.md");
|
||||
expect(sharedAPI.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains the prefix, level, and key selected by the host logger", async () => {
|
||||
const harness = createHarness({ compose: false });
|
||||
const { API, ...services } = harness.services;
|
||||
useConflictResolutionFeature({
|
||||
services,
|
||||
serviceModules: harness.serviceModules,
|
||||
log: harness.log,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
|
||||
await harness.conflict.resolveByDeletingRevision("same.md" as FilePathWithPrefix, "1-old", "same");
|
||||
await harness.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(API.addLog).toHaveBeenCalledWith(
|
||||
`[SF:ConflictResolution]${MARK_LOG_SEPARATOR} same.md has been merged automatically`,
|
||||
LOG_LEVEL_INFO,
|
||||
""
|
||||
);
|
||||
expect(API.addLog).toHaveBeenLastCalledWith("Done!", LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
});
|
||||
|
||||
it("keeps resolver operations on narrow collaborators and extension seams", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same.md" as FilePathWithPrefix;
|
||||
@@ -557,6 +646,7 @@ describe("conflict resolution serviceFeature", () => {
|
||||
useConflictResolutionFeature({
|
||||
services: harness.services,
|
||||
serviceModules: harness.serviceModules,
|
||||
log: harness.log,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
|
||||
for (const registration of registrations) {
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
createServiceFeature,
|
||||
type NecessaryServices,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { createConflictCheckingHandlers } from "./checker";
|
||||
import { createConflictResolutionOperations } from "./operations";
|
||||
|
||||
type ConflictResolutionServices = NecessaryServices<
|
||||
"API" | "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||
"databaseFileAccess" | "fileHandler" | "storageAccess"
|
||||
>;
|
||||
|
||||
export type ConflictResolutionHost = ConflictResolutionServices & {
|
||||
readonly services: ConflictResolutionServices["services"] & {
|
||||
readonly conflict: InjectableConflictService<ServiceContext>;
|
||||
};
|
||||
export type ConflictResolutionHost = NecessaryServices<
|
||||
"appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||
"databaseFileAccess" | "fileHandler" | "storageAccess",
|
||||
InjectableServiceHub
|
||||
> & {
|
||||
readonly log: LogFunction;
|
||||
};
|
||||
|
||||
/** Compose the host-neutral conflict checker and resolver handlers. */
|
||||
export function useConflictResolutionFeature(host: ConflictResolutionHost): void {
|
||||
const { services, serviceModules } = host;
|
||||
const log = createInstanceLogFunction("SF:ConflictResolution", services.API);
|
||||
/** Compose the host-neutral conflict handlers with the host's borrowed logger. */
|
||||
export const useConflictResolutionFeature = createServiceFeature((host: ConflictResolutionHost): void => {
|
||||
const { services, serviceModules, log } = host;
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: serviceModules.databaseFileAccess,
|
||||
@@ -48,7 +46,7 @@ export function useConflictResolutionFeature(host: ConflictResolutionHost): void
|
||||
services.conflict.resolve.setHandler(operations.resolve);
|
||||
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
|
||||
}
|
||||
});
|
||||
|
||||
export { createConflictCheckingHandlers } from "./checker";
|
||||
export type { ConflictCheckingDependencies, ConflictCheckingHandlers } from "./checker";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getLanguage, Notice, requireApiVersion } from "@/deps";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createServiceFeature } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@/common/rosetta";
|
||||
import { $msg, __onMissingTranslation, setLang } from "@/common/translation";
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
|
||||
type EnableI18nHost = NecessaryServices<"setting" | "API" | "appLifecycle", never>;
|
||||
|
||||
function tryGetLanguage(onError: (error: unknown) => void) {
|
||||
if (requireApiVersion("1.8.7")) {
|
||||
try {
|
||||
@@ -48,7 +51,8 @@ class ObsidianLanguageAppliedNotice {
|
||||
}
|
||||
}
|
||||
|
||||
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => {
|
||||
export const enableI18nFeature = createServiceFeature(async (host: EnableI18nHost) => {
|
||||
const { setting, API, appLifecycle } = host.services;
|
||||
// Clear missing translation handler to avoid unnecessary warnings.
|
||||
__onMissingTranslation(() => {});
|
||||
let isChanged = false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
|
||||
const noticeState = vi.hoisted(() => ({
|
||||
instances: [] as Array<{ hide: ReturnType<typeof vi.fn>; duration: number }>,
|
||||
@@ -63,6 +63,14 @@ describe("automatic display language", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("declares only the services it needs and returns a boolean promise", () => {
|
||||
type FeatureHost = Parameters<typeof enableI18nFeature>[0];
|
||||
|
||||
expectTypeOf<keyof FeatureHost["services"]>().toEqualTypeOf<"context" | "setting" | "API" | "appLifecycle">();
|
||||
expectTypeOf<keyof FeatureHost["serviceModules"]>().toBeNever();
|
||||
expectTypeOf<ReturnType<typeof enableI18nFeature>>().toEqualTypeOf<Promise<boolean>>();
|
||||
});
|
||||
|
||||
it("lets start-up continue and opens translation details only from a persistent Notice", async () => {
|
||||
const settings = { displayLanguage: "" };
|
||||
const applyPartial = vi.fn(async (partial: Partial<typeof settings>) => Object.assign(settings, partial));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCommandCheck } from "@vrtmrz/obsidian-plugin-kit/command";
|
||||
import { LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
@@ -112,33 +113,40 @@ export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setti
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-copysetupuri",
|
||||
name: "Copy settings as a new setup URI",
|
||||
checkCallback: (checking) => {
|
||||
if (!host.services.setting.currentSettings().isConfigured) return false;
|
||||
if (!checking) fireAndForget(copySetupURI(host, log));
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => host.services.setting.currentSettings().isConfigured,
|
||||
() => {
|
||||
fireAndForget(copySetupURI(host, log));
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-copysetupuri-short",
|
||||
name: "Copy settings as a new setup URI (With customization sync)",
|
||||
checkCallback: (checking) => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!settings.isConfigured || !settings.usePluginSync) return false;
|
||||
if (!checking) fireAndForget(copySetupURI(host, log, false));
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
return settings.isConfigured && settings.usePluginSync;
|
||||
},
|
||||
() => {
|
||||
fireAndForget(copySetupURI(host, log, false));
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-copysetupurifull",
|
||||
name: "Copy settings as a new setup URI (Full)",
|
||||
checkCallback: (checking) => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!settings.isConfigured || !settings.useAdvancedMode) return false;
|
||||
if (!checking) fireAndForget(copySetupURIFull(host, log));
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
return settings.isConfigured && settings.useAdvancedMode;
|
||||
},
|
||||
() => {
|
||||
fireAndForget(copySetupURIFull(host, log));
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
host.services.context.events.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () =>
|
||||
|
||||
@@ -221,7 +221,7 @@ describe("setupObsidian/setupUri", () => {
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => "pass"),
|
||||
askString: vi.fn(async () => false),
|
||||
},
|
||||
promptCopyToClipboard: vi.fn(() => true),
|
||||
},
|
||||
@@ -269,7 +269,7 @@ describe("setupObsidian/setupUri", () => {
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => "pass"),
|
||||
askString: vi.fn(async () => false),
|
||||
},
|
||||
promptCopyToClipboard: vi.fn(() => true),
|
||||
},
|
||||
@@ -292,5 +292,12 @@ describe("setupObsidian/setupUri", () => {
|
||||
settings.useAdvancedMode = true;
|
||||
expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(true);
|
||||
expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(true);
|
||||
expect(host.services.UI.confirm.askString).not.toHaveBeenCalled();
|
||||
settings.isConfigured = false;
|
||||
for (const registered of commands) expect(registered.checkCallback?.(false)).toBe(false);
|
||||
expect(host.services.UI.confirm.askString).not.toHaveBeenCalled();
|
||||
settings.isConfigured = true;
|
||||
for (const registered of commands) expect(registered.checkCallback?.(false)).toBe(true);
|
||||
expect(host.services.UI.confirm.askString).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCommandCheck } from "@vrtmrz/obsidian-plugin-kit/command";
|
||||
import {
|
||||
createServiceFeature,
|
||||
type NecessaryServices,
|
||||
@@ -46,22 +47,23 @@ export const useDatabaseDiagnosticsFeature = createServiceFeature((host: Databas
|
||||
id: "analyse-database",
|
||||
name: "Analyse Database Usage (advanced)",
|
||||
icon: "database-search",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode || !services.database.isDatabaseReady())
|
||||
return false;
|
||||
if (!checking) void open("usage");
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.setting.currentSettings().useAdvancedMode && services.database.isDatabaseReady(),
|
||||
() => {
|
||||
void open("usage");
|
||||
}
|
||||
),
|
||||
});
|
||||
services.API.addCommand({
|
||||
id: "inspect-database-integrity",
|
||||
name: $msg("Inspect database integrity"),
|
||||
icon: "database-search",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.database.isDatabaseReady()) return false;
|
||||
if (!checking) void open("integrity");
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.database.isDatabaseReady(),
|
||||
() => {
|
||||
void open("integrity");
|
||||
}
|
||||
),
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
@@ -82,6 +82,26 @@ describe("database diagnostics serviceFeature", () => {
|
||||
expect(data.openDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["analyse-database", "inspect-database-integrity"])(
|
||||
"rechecks database availability before executing %s",
|
||||
async (id) => {
|
||||
const data = fixture();
|
||||
data.settings.useAdvancedMode = true;
|
||||
for (const initialise of data.initialise) await initialise();
|
||||
const command = data.commands.find((candidate) => candidate.id === id)!;
|
||||
expect(command.checkCallback(true)).toBe(true);
|
||||
expect(data.openDialog).not.toHaveBeenCalled();
|
||||
data.setReady(false);
|
||||
expect(command.checkCallback(false)).toBe(false);
|
||||
expect(data.openDialog).not.toHaveBeenCalled();
|
||||
data.setReady(true);
|
||||
expect(command.checkCallback(false)).toBe(true);
|
||||
expect(data.openDialog).toHaveBeenCalledOnce();
|
||||
expect(data.openDialog.mock.calls[0][1].kind).toBe(id === "analyse-database" ? "usage" : "integrity");
|
||||
data.close();
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the existing dialogue manager and performs no analysis until the run choice is submitted", async () => {
|
||||
vi.mocked(runDatabaseDiagnostic).mockClear();
|
||||
const data = fixture();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eventHub, EVENT_REQUEST_OPEN_P2P } from "@/common/events";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import {
|
||||
P2PServerStatusPaneView,
|
||||
VIEW_TYPE_P2P_SERVER_STATUS,
|
||||
@@ -47,22 +47,9 @@ export function hasP2PConfiguration(settings: Partial<ObsidianLiveSyncSettings>)
|
||||
*/
|
||||
|
||||
export function useP2PReplicatorUI(
|
||||
host: NecessaryServices<
|
||||
| "API"
|
||||
| "appLifecycle"
|
||||
| "setting"
|
||||
| "vault"
|
||||
| "database"
|
||||
| "databaseEvents"
|
||||
| "keyValueDB"
|
||||
| "replication"
|
||||
| "config"
|
||||
| "UI"
|
||||
| "replicator",
|
||||
never
|
||||
>,
|
||||
host: NecessaryServices<"API" | "appLifecycle" | "setting" | "replicator", never>,
|
||||
core: LiveSyncCore,
|
||||
replicator: UseP2PReplicatorResult,
|
||||
replicator: P2PServiceViews,
|
||||
openInteractiveReplication: OpenInteractiveP2PReplication
|
||||
) {
|
||||
const api = host.services.API as {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { eventHub, EVENT_REQUEST_OPEN_P2P } from "@/common/events";
|
||||
|
||||
@@ -16,6 +18,19 @@ import { useP2PReplicatorUI } from "./useP2PReplicatorUI";
|
||||
const noopOpenInteractiveReplication = () => Promise.resolve(false);
|
||||
|
||||
describe("useP2PReplicatorUI commands", () => {
|
||||
it("accepts only the UI services and focused P2P views", () => {
|
||||
type Dependencies = NecessaryServices<"API" | "appLifecycle" | "setting" | "replicator", never>;
|
||||
type Arguments = Parameters<typeof useP2PReplicatorUI>;
|
||||
|
||||
expectTypeOf<Dependencies>().toExtend<Arguments[0]>();
|
||||
expectTypeOf<keyof Arguments[0]["services"]>().toEqualTypeOf<
|
||||
"context" | "API" | "appLifecycle" | "setting" | "replicator"
|
||||
>();
|
||||
expectTypeOf<keyof Arguments[0]["serviceModules"]>().toBeNever();
|
||||
expectTypeOf<P2PServiceViews>().toExtend<Arguments[2]>();
|
||||
expectTypeOf<Arguments[2]>().toEqualTypeOf<P2PServiceViews>();
|
||||
});
|
||||
|
||||
it("waits for settings to load before deciding whether to show the P2P ribbon", async () => {
|
||||
let initialise: (() => Promise<unknown>) | undefined;
|
||||
let settingLoaded: (() => Promise<unknown>) | undefined;
|
||||
|
||||
Reference in new issue
Block a user