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
@@ -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();