Limit commands to applicable contexts

This commit is contained in:
vorotamoroz
2026-07-24 16:13:49 +00:00
parent 6afeb0b409
commit 0e5475b7e3
27 changed files with 727 additions and 75 deletions
@@ -64,6 +64,8 @@ export const liveSyncProvisionalEnglishMessages = {
"This file has unresolved conflicts.": "This file has unresolved conflicts.",
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.":
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.",
"Sync now": "Sync now",
"Apply pending changes now": "Apply pending changes now",
} as const;
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
Notice: class Notice {},
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
vi.mock("@/features/LiveSyncCommands.ts", () => ({
LiveSyncCommands: class LiveSyncCommands {
core!: { services: unknown };
get services() {
return this.core.services;
}
},
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
EVEN: Symbol("even"),
disposeMemoObject: vi.fn(),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
memoIfNotExist: vi.fn(),
memoObject: vi.fn(),
retrieveMemoObject: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { ConfigSync } from "./CmdConfigSync";
describe("ConfigSync commands", () => {
it("shows the Customisation Sync command only whilst the feature is enabled", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings = {
usePluginSync: false,
};
const showPluginSyncModal = vi.fn();
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: {
settings,
services: {
API: {
addCommand: vi.fn((command) => commands.push(command)),
},
},
},
addRibbonIcon: vi.fn(() => ({
addClass: vi.fn(),
})),
showPluginSyncModal,
});
configSync.onload();
const command = commands.find(({ id }) => id === "livesync-plugin-dialog-ex");
expect(command?.checkCallback?.(true)).toBe(false);
settings.usePluginSync = true;
expect(command?.checkCallback?.(true)).toBe(true);
expect(command?.checkCallback?.(false)).toBe(true);
expect(showPluginSyncModal).toHaveBeenCalledOnce();
});
});
+8 -2
View File
@@ -454,8 +454,14 @@ export class ConfigSync extends LiveSyncCommands {
this.services.API.addCommand({
id: "livesync-plugin-dialog-ex",
name: "Show customization sync dialog",
callback: () => {
this.showPluginSyncModal();
checkCallback: (checking) => {
if (!this.isThisModuleEnabled()) {
return false;
}
if (!checking) {
this.showPluginSyncModal();
}
return true;
},
});
this.addRibbonIcon("custom-sync", $msg("cmdConfigSync.showCustomizationSync"), () => {
@@ -54,10 +54,7 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import {
configureHiddenFileSyncMode,
type ConfigureHiddenFileSyncResult,
} from "./configureHiddenFileSyncMode.ts";
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
@@ -109,37 +106,45 @@ export class HiddenFileSync extends LiveSyncCommands {
this.services.API.addCommand({
id: "livesync-sync-internal",
name: "(re)initialise hidden files between storage and database",
callback: () => {
if (this.isReady()) {
checkCallback: (checking) => {
if (!this.isManualCommandAvailable()) return false;
if (!checking) {
void this.initialiseInternalFileSync("safe", true);
}
return true;
},
});
this.services.API.addCommand({
id: "livesync-scaninternal-storage",
name: "Scan hidden file changes on the storage",
callback: () => {
if (this.isReady()) {
checkCallback: (checking) => {
if (!this.isManualCommandAvailable()) return false;
if (!checking) {
void this.scanAllStorageChanges(true);
}
return true;
},
});
this.services.API.addCommand({
id: "livesync-scaninternal-database",
name: "Scan hidden file changes on the local database",
callback: () => {
if (this.isReady()) {
checkCallback: (checking) => {
if (!this.isManualCommandAvailable()) return false;
if (!checking) {
void this.scanAllDatabaseChanges(true);
}
return true;
},
});
this.services.API.addCommand({
id: "livesync-internal-scan-offline-changes",
name: "Scan and apply all offline hidden-file changes",
callback: () => {
if (this.isReady()) {
checkCallback: (checking) => {
if (!this.isManualCommandAvailable()) return false;
if (!checking) {
void this.applyOfflineChanges(true);
}
return true;
},
});
eventHub.onEvent(EVENT_SETTING_SAVED, () => {
@@ -191,12 +196,16 @@ export class HiddenFileSync extends LiveSyncCommands {
}
isReady() {
if (!this._isMainReady) return false;
if (!this._isMainReady()) return false;
if (this._isMainSuspended()) return false;
if (!this.isThisModuleEnabled()) return false;
return true;
}
private isManualCommandAvailable() {
return this.settings.useAdvancedMode && this.isReady() && this._isDatabaseReady();
}
async performStartupScan(showNotice: boolean) {
await this.applyOfflineChanges(showNotice);
}
@@ -8,13 +8,16 @@ vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
vi.mock("@/features/LiveSyncCommands.ts", () => ({
LiveSyncCommands: class LiveSyncCommands {
plugin!: { app: unknown };
core!: { services: unknown };
core!: { services: unknown; settings: unknown };
get app() {
return this.plugin.app;
}
get services() {
return this.core.services;
}
get settings() {
return this.core.settings;
}
},
}));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
@@ -25,6 +28,66 @@ import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
describe("HiddenFileSync configuration-change notices", () => {
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings = {
syncInternalFiles: false,
useAdvancedMode: false,
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings,
services: {
API: {
addCommand: vi.fn((command) => commands.push(command)),
},
},
},
_isMainReady: vi.fn(() => true),
_isMainSuspended: vi.fn(() => false),
_isDatabaseReady: vi.fn(() => true),
});
hiddenFileSync.onload();
const commandIds = [
"livesync-sync-internal",
"livesync-scaninternal-storage",
"livesync-scaninternal-database",
"livesync-internal-scan-offline-changes",
];
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(false);
}
settings.syncInternalFiles = true;
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(true);
}
});
it("does not report Hidden File Sync as ready before the main runtime is ready", () => {
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: {
syncInternalFiles: true,
},
},
_isMainReady: vi.fn(() => false),
_isMainSuspended: vi.fn(() => false),
});
expect(hiddenFileSync.isReady()).toBe(false);
});
it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => {
const noticeGroups = {
setItem: vi.fn(),
@@ -4,6 +4,8 @@ import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_P2P,
type DocumentID,
type EntryDoc,
type EntryLeaf,
@@ -38,16 +40,28 @@ export class LocalDatabaseMaintenance extends LiveSyncCommands {
id: "analyse-database",
name: "Analyse Database Usage (advanced)",
icon: "database-search",
callback: async () => {
await this.analyseDatabase();
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode || !this._isDatabaseReady()) return false;
if (!checking) {
void this.analyseDatabase();
}
return true;
},
});
this.plugin.addCommand({
id: "gc-v3",
name: "Garbage Collection V3 (advanced, beta)",
icon: "trash-2",
callback: async () => {
await this.gcv3();
checkCallback: (checking) => {
const isApplicableRemote =
this.settings.remoteType === REMOTE_COUCHDB || this.settings.remoteType === REMOTE_P2P;
if (!this.settings.useEdgeCaseMode || !this._isDatabaseReady() || !isApplicableRemote) {
return false;
}
if (!checking) {
void this.gcv3();
}
return true;
},
});
eventHub.onEvent(EVENT_ANALYSE_DB_USAGE, () => this.analyseDatabase());
@@ -1,5 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("octagonal-wheels/number", () => ({
sizeToHumanReadable: vi.fn((value: number) => `${value} B`),
}));
vi.mock("octagonal-wheels/concurrency/lock_v2", () => ({
serialized: vi.fn((_key: string, task: () => unknown) => task()),
}));
vi.mock("octagonal-wheels/collection", () => ({
arrayToChunkedArray: vi.fn((values: unknown[]) => [values]),
}));
vi.mock("@/features/LiveSyncCommands", () => ({
LiveSyncCommands: class LiveSyncCommands {
core!: { settings: unknown };
get settings() {
return this.core.settings;
}
},
}));
vi.mock("@/common/events", () => ({
EVENT_ANALYSE_DB_USAGE: "analyse",
EVENT_REQUEST_PERFORM_GC_V3: "gc",
eventHub: {
onEvent: vi.fn(),
},
}));
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte";
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
@@ -22,6 +48,49 @@ function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS>
}
describe("LocalDatabaseMaintenance prerequisites", () => {
it("shows database analysis in Advanced mode and Garbage Collection only in applicable Edge Case mode", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings: {
useAdvancedMode: boolean;
useEdgeCaseMode: boolean;
remoteType: string;
} = {
useAdvancedMode: false,
useEdgeCaseMode: false,
remoteType: REMOTE_COUCHDB,
};
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
Object.assign(maintenance, {
plugin: {
addCommand: vi.fn((command) => commands.push(command)),
},
core: {
settings,
},
_isDatabaseReady: vi.fn(() => true),
});
maintenance.onload();
const analyse = commands.find(({ id }) => id === "analyse-database");
const garbageCollect = commands.find(({ id }) => id === "gc-v3");
expect(analyse?.checkCallback?.(true)).toBe(false);
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.useAdvancedMode = true;
expect(analyse?.checkCallback?.(true)).toBe(true);
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.useEdgeCaseMode = true;
expect(garbageCollect?.checkCallback?.(true)).toBe(true);
settings.remoteType = REMOTE_MINIO;
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
});
it("asks to disable on-demand chunk fetching before maintenance actions", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
+15 -6
View File
@@ -2,13 +2,14 @@ import type { LiveSyncCore } from "@/main";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { fireAndForget } from "octagonal-wheels/promises";
import { AbstractModule } from "@/modules/AbstractModule";
import { $msg } from "@/common/translation";
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
export class ModuleBasicMenu extends AbstractModule {
_everyOnloadStart(): Promise<boolean> {
this.addCommand({
id: "livesync-replicate",
name: "Replicate now",
name: $msg("Sync now"),
callback: async () => {
await this.services.replication.replicate();
},
@@ -56,14 +57,18 @@ export class ModuleBasicMenu extends AbstractModule {
this.addCommand({
id: "livesync-scan-files",
name: "Scan storage and database again",
callback: async () => {
await this.services.vault.scanVault(true);
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode) return false;
if (!checking) {
fireAndForget(() => this.services.vault.scanVault(true));
}
return true;
},
});
this.addCommand({
id: "livesync-runbatch",
name: "Run pended batch processes",
name: $msg("Apply pending changes now"),
callback: async () => {
await this.services.fileProcessing.commitPendingFileEvents();
},
@@ -73,8 +78,12 @@ export class ModuleBasicMenu extends AbstractModule {
this.addCommand({
id: "livesync-abortsync",
name: "Abort synchronization immediately",
callback: () => {
this.core.replicator.terminateSync();
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode) return false;
if (!checking) {
this.core.replicator.terminateSync();
}
return true;
},
});
return Promise.resolve(true);
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import type { Command } from "@/deps";
import { ModuleBasicMenu } from "./ModuleBasicMenu";
type RegisteredCommand = Command & {
checkCallback?: (checking: boolean) => boolean | void;
};
function createFixture() {
const commands: RegisteredCommand[] = [];
const settings = {
liveSync: false,
useAdvancedMode: false,
enableDebugTools: false,
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn((command: RegisteredCommand) => {
commands.push(command);
return command;
}),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
replication: {
replicate: vi.fn(async () => undefined),
},
vault: {
getActiveFilePath: vi.fn((): string | null => "note.md"),
scanVault: vi.fn(async () => undefined),
},
control: {
applySettings: vi.fn(async () => undefined),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
appLifecycle: {
isSuspended: vi.fn(() => false),
setSuspended: vi.fn(),
},
fileProcessing: {
commitPendingFileEvents: vi.fn(async () => true),
},
UI: {
promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true),
},
path: {
path2id: vi.fn(async () => "f:note"),
},
};
const core = {
settings,
_services: services,
services,
localDatabase: {
getDBEntry: vi.fn(async () => false),
localDatabase: {
get: vi.fn(async () => ({
_id: "f:note",
_rev: "2-current",
_conflicts: [],
path: "note.md",
ctime: 100,
mtime: 200,
size: 12,
type: "plain",
children: ["h:private-chunk-id"],
eden: {},
})),
},
getDBEntryMeta: vi.fn(async () => ({
_id: "f:note",
_rev: "2-current",
_conflicts: [],
path: "note.md",
ctime: 100,
mtime: 200,
size: 12,
type: "plain",
datatype: "plain",
data: "",
children: ["h:private-chunk-id"],
eden: {},
})),
allDocsRaw: vi.fn(async () => ({
rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }],
})),
},
storageAccess: {
isExistsIncludeHidden: vi.fn(async () => true),
statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })),
},
replicator: {
terminateSync: vi.fn(),
},
};
const module = new ModuleBasicMenu(core as never);
return {
commands,
core,
module,
services,
settings,
getCommand(id: string) {
const command = commands.find((candidate) => candidate.id === id);
expect(command, `command ${id}`).toBeDefined();
return command!;
},
};
}
describe("ModuleBasicMenu command palette", () => {
it("uses clear user-facing names without changing the established command IDs", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
});
it("keeps maintenance commands out of the normal palette", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
fixture.settings.useAdvancedMode = true;
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
});
});
@@ -12,7 +12,7 @@ import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts
import type { PageFunctions } from "./SettingPane.ts";
import { visibleOnly } from "./SettingPane.ts";
import { request } from "@/deps.ts";
import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import {
createCoreSettingsAfterFullReset,
@@ -40,8 +40,7 @@ export function paneSetup(
.addButton((text) => {
text.setButtonText($msg("Rerun Wizard")).onClick(async () => {
const setupManager = this.core.getModule(SetupManager);
await setupManager.onOnboard(UserMode.ExistingUser);
// await this.plugin.moduleSetupObsidian.onBoardingWizard(true);
await setupManager.startOnBoarding();
});
});
+5 -1
View File
@@ -65,7 +65,11 @@ export function useSetupQRCodeFeature(host: NecessaryServices<"API" | "UI" | "se
host.services.API.addCommand({
id: "livesync-setting-qr",
name: "Show settings as a QR code",
callback: () => fireAndForget(encodeSetupSettingsAsQR(host)),
checkCallback: (checking) => {
if (!host.services.setting.currentSettings().isConfigured) return false;
if (!checking) fireAndForget(encodeSetupSettingsAsQR(host));
return true;
},
});
host.services.context.events.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () =>
fireAndForget(() => encodeSetupSettingsAsQR(host))
@@ -114,4 +114,44 @@ describe("setupObsidian/qrCode", () => {
);
expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_SHOW_SETUP_QR, expect.any(Function));
});
it("keeps the QR command out of the palette until setup is complete", async () => {
const addHandler = vi.fn();
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings = { isConfigured: false };
const host = {
services: {
context: createServiceContext(),
API: {
addCommand: vi.fn((command) => commands.push(command)),
},
appLifecycle: {
onLoaded: {
addHandler,
},
},
setting: {
currentSettings: vi.fn(() => settings),
},
UI: {
confirm: {
confirmWithMessage: vi.fn(),
},
},
},
} as any;
useSetupQRCodeFeature(host);
const loadedHandler = addHandler.mock.calls[0][0] as () => Promise<boolean>;
await loadedHandler();
const command = commands.find((candidate) => candidate.id === "livesync-setting-qr")!;
expect(command.checkCallback?.(true)).toBe(false);
settings.isConfigured = true;
expect(command.checkCallback?.(true)).toBe(true);
});
});
@@ -47,11 +47,6 @@ export function useSetupManagerHandlersFeature(
setupManager: SetupManager
) {
host.services.appLifecycle.onLoaded.addHandler(() => {
host.services.API.addCommand({
id: "livesync-open-onboarding",
name: "Open onboarding wizard",
callback: () => fireAndForget(() => openOnboarding(setupManager)),
});
host.services.API.addCommand({
id: "livesync-opensetupuri",
name: "Use the copied setup URI (Formerly Open setup URI)",
@@ -109,7 +109,7 @@ describe("setupObsidian/setupManagerHandlers", () => {
expect(preventDefault).toHaveBeenCalledOnce();
});
it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => {
it("keeps onboarding out of the command palette while wiring the setup URI command and events", async () => {
const addHandler = vi.fn();
const addCommand = vi.fn();
const events = { onEvent: vi.fn() };
@@ -147,10 +147,9 @@ describe("setupObsidian/setupManagerHandlers", () => {
const loadedHandler = addHandler.mock.calls[0][0] as () => Promise<boolean>;
await loadedHandler();
expect(addCommand).toHaveBeenCalledWith(
expect(addCommand).not.toHaveBeenCalledWith(
expect.objectContaining({
id: "livesync-open-onboarding",
name: "Open onboarding wizard",
})
);
expect(addCommand).toHaveBeenCalledWith(
+17 -3
View File
@@ -50,19 +50,33 @@ export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setti
host.services.API.addCommand({
id: "livesync-copysetupuri",
name: "Copy settings as a new setup URI",
callback: () => fireAndForget(copySetupURI(host, log)),
checkCallback: (checking) => {
if (!host.services.setting.currentSettings().isConfigured) return false;
if (!checking) fireAndForget(copySetupURI(host, log));
return true;
},
});
host.services.API.addCommand({
id: "livesync-copysetupuri-short",
name: "Copy settings as a new setup URI (With customization sync)",
callback: () => fireAndForget(copySetupURI(host, log, false)),
checkCallback: (checking) => {
const settings = host.services.setting.currentSettings();
if (!settings.isConfigured || !settings.usePluginSync) return false;
if (!checking) fireAndForget(copySetupURI(host, log, false));
return true;
},
});
host.services.API.addCommand({
id: "livesync-copysetupurifull",
name: "Copy settings as a new setup URI (Full)",
callback: () => fireAndForget(copySetupURIFull(host, log)),
checkCallback: (checking) => {
const settings = host.services.setting.currentSettings();
if (!settings.isConfigured || !settings.useAdvancedMode) return false;
if (!checking) fireAndForget(copySetupURIFull(host, log));
return true;
},
});
host.services.context.events.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () =>
@@ -156,4 +156,57 @@ describe("setupObsidian/setupUri", () => {
expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupurifull" }));
expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_COPY_SETUP_URI, expect.any(Function));
});
it("shows Setup URI variants only when their configuration level is relevant", async () => {
const addHandler = vi.fn();
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings = {
isConfigured: false,
usePluginSync: false,
useAdvancedMode: false,
};
const host = {
services: {
context: createServiceContext(),
API: {
addCommand: vi.fn((command) => commands.push(command)),
addLog: vi.fn(),
},
appLifecycle: {
onLoaded: {
addHandler,
},
},
setting: {
currentSettings: vi.fn(() => settings),
},
UI: {
confirm: {
askString: vi.fn(() => "pass"),
},
promptCopyToClipboard: vi.fn(() => true),
},
},
} as any;
useSetupURIFeature(host);
const loadedHandler = addHandler.mock.calls[0][0] as () => Promise<boolean>;
await loadedHandler();
const command = (id: string) => commands.find((candidate) => candidate.id === id)!;
expect(command("livesync-copysetupuri").checkCallback?.(true)).toBe(false);
settings.isConfigured = true;
expect(command("livesync-copysetupuri").checkCallback?.(true)).toBe(true);
expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(false);
expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(false);
settings.usePluginSync = true;
settings.useAdvancedMode = true;
expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(true);
expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(true);
});
});
+35 -14
View File
@@ -67,7 +67,12 @@ export function useP2PReplicatorUI(
showWindow: (type: string) => Promise<void>;
showWindowOnRight?: (type: string) => Promise<void>;
registerWindow: (type: string, factory: (leaf: WorkspaceLeaf) => unknown) => void;
addCommand: (command: { id: string; name: string; callback: () => void }) => unknown;
addCommand: (command: {
id: string;
name: string;
callback?: () => void;
checkCallback?: (checking: boolean) => boolean | void;
}) => unknown;
addRibbonIcon: (
icon: string,
title: string,
@@ -146,8 +151,12 @@ export function useP2PReplicatorUI(
api.addCommand({
id: "open-p2p-server-status",
name: "P2P Sync : Open P2P Status",
callback: () => {
void openStatusPane();
checkCallback: (checking) => {
if (!hasP2PConfiguration(host.services.setting.currentSettings())) return false;
if (!checking) {
void openStatusPane();
}
return true;
},
});
host.services.API.addCommand({
@@ -155,11 +164,15 @@ export function useP2PReplicatorUI(
name: "Replicate P2P to default peer",
checkCallback: (isChecking: boolean) => {
const settings = host.services.setting.currentSettings();
if (isChecking) {
if (settings.remoteType == REMOTE_P2P) return false;
return replicator.replicator?.server?.isServing ?? false;
const isAvailable =
hasP2PConfiguration(settings) &&
settings.remoteType !== REMOTE_P2P &&
(replicator.replicator?.server?.isServing ?? false);
if (!isAvailable) return false;
if (!isChecking) {
runOpenReplication();
}
runOpenReplication();
return true;
},
});
host.services.API.addCommand({
@@ -167,11 +180,15 @@ export function useP2PReplicatorUI(
name: "Replicate now by P2P",
checkCallback: (isChecking: boolean) => {
const settings = host.services.setting.currentSettings();
if (isChecking) {
if (settings.remoteType == REMOTE_P2P) return false;
return replicator.replicator?.server?.isServing ?? false;
const isAvailable =
hasP2PConfiguration(settings) &&
settings.remoteType !== REMOTE_P2P &&
(replicator.replicator?.server?.isServing ?? false);
if (!isAvailable) return false;
if (!isChecking) {
runOpenReplication();
}
runOpenReplication();
return true;
},
});
@@ -179,10 +196,14 @@ export function useP2PReplicatorUI(
id: "p2p-sync-targets",
name: "P2P: Sync with targets",
checkCallback: (isChecking: boolean) => {
if (isChecking) {
return replicator.replicator?.server?.isServing ?? false;
const isAvailable =
hasP2PConfiguration(host.services.setting.currentSettings()) &&
(replicator.replicator?.server?.isServing ?? false);
if (!isAvailable) return false;
if (!isChecking) {
void replicator.replicator?.replicateFromCommand(true);
}
void replicator.replicator?.replicateFromCommand(true);
return true;
},
});
@@ -85,7 +85,12 @@ describe("useP2PReplicatorUI commands", () => {
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: { addHandler: vi.fn() },
},
setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) },
setting: {
currentSettings: vi.fn(() => ({
remoteType: "COUCHDB",
P2P_Enabled: true,
})),
},
replicator: { runFiniteReplicationActivity },
},
} as any;
@@ -143,7 +148,11 @@ describe("useP2PReplicatorUI commands", () => {
});
it("retains only the current P2P status command and routes existing open requests to it", async () => {
const commands: Array<{ id: string; callback?: () => void }> = [];
const commands: Array<{
id: string;
callback?: () => void;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
let initialise: (() => Promise<unknown>) | undefined;
const showWindow = vi.fn(async () => undefined);
const showWindowOnRight = vi.fn(async () => undefined);
@@ -183,12 +192,90 @@ describe("useP2PReplicatorUI commands", () => {
expect(commands.map((command) => command.id)).not.toContain("open-p2p-replicator");
expect(commands.map((command) => command.id)).toContain("open-p2p-server-status");
expect(commands.find((command) => command.id === "open-p2p-server-status")?.checkCallback?.(true)).toBe(false);
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P);
await vi.waitFor(() => expect(showWindowOnRight).toHaveBeenCalledWith("p2p-status"));
expect(showWindow).not.toHaveBeenCalledWith("p2p");
});
it("shows P2P commands only when a P2P configuration exists and their runtime prerequisites are met", async () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
let initialise: (() => Promise<unknown>) | undefined;
let settings: Record<string, unknown> = {
remoteType: "COUCHDB",
remoteConfigurations: {},
};
const host = {
services: {
context: createServiceContext(),
API: {
showWindow: vi.fn(async () => undefined),
showWindowOnRight: vi.fn(async () => undefined),
registerWindow: vi.fn(),
addCommand: vi.fn((command) => commands.push(command)),
addRibbonIcon: vi.fn(),
},
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler) => {
initialise = handler;
}),
},
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: { addHandler: vi.fn() },
},
setting: {
currentSettings: vi.fn(() => settings),
onSettingSaved: { addHandler: vi.fn() },
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
const p2p = {
replicator: {
server: { isServing: true },
openReplication: vi.fn(),
replicateFromCommand: vi.fn(),
},
} as any;
useP2PReplicatorUI(host, {} as any, p2p);
await initialise?.();
for (const commandId of [
"open-p2p-server-status",
"replicate-now-by-p2p-default-peer",
"replicate-now-by-p2p",
"p2p-sync-targets",
]) {
expect(commands.find(({ id }) => id === commandId)?.checkCallback?.(true)).toBe(false);
}
settings = {
...settings,
remoteConfigurations: {
peer: {
id: "peer",
name: "Peer",
uri: "sls+p2p://room?passphrase=secret",
isEncrypted: false,
},
},
};
for (const commandId of [
"open-p2p-server-status",
"replicate-now-by-p2p-default-peer",
"replicate-now-by-p2p",
"p2p-sync-targets",
]) {
expect(commands.find(({ id }) => id === commandId)?.checkCallback?.(true)).toBe(true);
}
});
it("does not open the P2P status pane automatically when the workspace becomes ready", async () => {
let layoutReady: (() => Promise<unknown>) | undefined;
const showWindow = vi.fn(async () => undefined);