From cf9d2720cec2030e82de0c3362e62e5a154557dd Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 3 Mar 2026 13:19:22 +0000 Subject: [PATCH] ### Fixed - No longer deleted files are not clickable in the Global History pane. - Diff view now uses more specific classes (#803). - A message of configuration mismatching slightly added for better understanding. - Now it says `When replication is initiated manually via the command palette or ribbon, a dialogue box will open to address this.` to make it clear that the user can fix the issue by themselves. ### Refactored - `ModuleRedFlag` has been refactored to `serviceFeatures/redFlag` and also tested. - `ModuleInitializerFile` has been refactored to `lib/serviceFeatures/offlineScanner` and also tested. --- .../HiddenFileCommon/JsonResolvePane.svelte | 2 +- src/lib | 2 +- src/main.ts | 12 +- src/managers/StorageEventManagerObsidian.ts | 5 +- ...e.ts => ModuleInitializerFile_obsolete.ts} | 4 +- .../GlobalHistory/GlobalHistory.svelte | 6 +- .../ConflictResolveModal.ts | 2 + src/modules/services/ObsidianVaultService.ts | 5 +- src/serviceFeatures/redFlag.ts | 387 ++++++ src/serviceFeatures/redFlag.unit.spec.ts | 1140 +++++++++++++++++ styles.css | 6 +- 11 files changed, 1554 insertions(+), 17 deletions(-) rename src/modules/essential/{ModuleInitializerFile.ts => ModuleInitializerFile_obsolete.ts} (99%) create mode 100644 src/serviceFeatures/redFlag.ts create mode 100644 src/serviceFeatures/redFlag.unit.spec.ts diff --git a/src/features/HiddenFileCommon/JsonResolvePane.svelte b/src/features/HiddenFileCommon/JsonResolvePane.svelte index 034b02f..bb1b22b 100644 --- a/src/features/HiddenFileCommon/JsonResolvePane.svelte +++ b/src/features/HiddenFileCommon/JsonResolvePane.svelte @@ -143,7 +143,7 @@ {#if selectedObj != false} -
+
{#each diffs as diff} {diff[1]} feature(this); this.services.appLifecycle.onLayoutReady.addHandler(curriedFeature); } + useRedFlagFeatures(this); + useOfflineScanner(this); // enable target filter feature. useTargetFilters(this); useCheckRemoteSize(this); diff --git a/src/managers/StorageEventManagerObsidian.ts b/src/managers/StorageEventManagerObsidian.ts index 3f5fdf7..979ccdc 100644 --- a/src/managers/StorageEventManagerObsidian.ts +++ b/src/managers/StorageEventManagerObsidian.ts @@ -2,10 +2,7 @@ import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync"; import type { FilePath } from "@lib/common/types"; import type ObsidianLiveSyncPlugin from "@/main"; import type { LiveSyncCore } from "@/main"; -import { - StorageEventManagerBase, - type StorageEventManagerBaseDependencies, -} from "@lib/managers/StorageEventManager"; +import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@lib/managers/StorageEventManager"; import { ObsidianStorageEventManagerAdapter } from "./ObsidianStorageEventManagerAdapter"; export class StorageEventManagerObsidian extends StorageEventManagerBase { diff --git a/src/modules/essential/ModuleInitializerFile.ts b/src/modules/essential/ModuleInitializerFile_obsolete.ts similarity index 99% rename from src/modules/essential/ModuleInitializerFile.ts rename to src/modules/essential/ModuleInitializerFile_obsolete.ts index c4e047b..b122b94 100644 --- a/src/modules/essential/ModuleInitializerFile.ts +++ b/src/modules/essential/ModuleInitializerFile_obsolete.ts @@ -423,7 +423,7 @@ export class ModuleInitializerFile extends AbstractModule { } override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void { services.appLifecycle.getUnresolvedMessages.addHandler(this._reportDetectedErrors.bind(this)); - services.databaseEvents.initialiseDatabase.setHandler(this._initializeDatabase.bind(this)); - services.vault.scanVault.setHandler(this._performFullScan.bind(this)); + services.databaseEvents.initialiseDatabase.addHandler(this._initializeDatabase.bind(this)); + services.vault.scanVault.addHandler(this._performFullScan.bind(this)); } } diff --git a/src/modules/features/GlobalHistory/GlobalHistory.svelte b/src/modules/features/GlobalHistory/GlobalHistory.svelte index 5e815e0..a30177b 100644 --- a/src/modules/features/GlobalHistory/GlobalHistory.svelte +++ b/src/modules/features/GlobalHistory/GlobalHistory.svelte @@ -250,7 +250,11 @@ - openFile(entry.path)}>{entry.filename} + {#if entry.isDeleted} + {entry.filename} + {:else} + openFile(entry.path)}>{entry.filename} + {/if}
diff --git a/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts b/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts index 826fbec..ad308e5 100644 --- a/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts +++ b/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts @@ -63,6 +63,7 @@ export class ConflictResolveModal extends Modal { contentEl.createEl("span", { text: this.filename }); const div = contentEl.createDiv(""); div.addClass("op-scrollable"); + div.addClass("ls-dialog"); let diff = ""; for (const v of this.result.diff) { const x1 = v[0]; @@ -86,6 +87,7 @@ export class ConflictResolveModal extends Modal { } const div2 = contentEl.createDiv(""); + div2.addClass("ls-dialog"); const date1 = new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : ""); const date2 = diff --git a/src/modules/services/ObsidianVaultService.ts b/src/modules/services/ObsidianVaultService.ts index 4229e00..480c561 100644 --- a/src/modules/services/ObsidianVaultService.ts +++ b/src/modules/services/ObsidianVaultService.ts @@ -1,4 +1,4 @@ -import { getPathFromTFile } from "@/common/utils"; +import { getPathFromTFile, isValidPath } from "@/common/utils"; import { InjectableVaultService } from "@/lib/src/services/implements/injectable/InjectableVaultService"; import type { ObsidianServiceContext } from "@/lib/src/services/implements/obsidian/ObsidianServiceContext"; import type { FilePath } from "@/lib/src/common/types"; @@ -30,4 +30,7 @@ export class ObsidianVaultService extends InjectableVaultService Promise; + handle: () => Promise; +} + +export async function isFlagFileExist(host: NecessaryServices, path: string) { + const redFlagExist = await host.serviceModules.storageAccess.isExists( + host.serviceModules.storageAccess.normalisePath(path) + ); + if (redFlagExist) { + return true; + } + return false; +} + +export async function deleteFlagFile(host: NecessaryServices, log: LogFunction, path: string) { + try { + const isFlagged = await host.serviceModules.storageAccess.isExists( + host.serviceModules.storageAccess.normalisePath(path) + ); + if (isFlagged) { + await host.serviceModules.storageAccess.delete(path, true); + } + } catch (ex) { + log(`Could not delete ${path}`); + log(ex, LOG_LEVEL_VERBOSE); + } +} +/** + * Factory function to create a fetch all flag handler. + * All logic related to fetch all flag is encapsulated here. + */ +export function createFetchAllFlagHandler( + host: NecessaryServices< + "vault" | "fileProcessing" | "tweakValue" | "UI" | "setting" | "appLifecycle", + "storageAccess" | "rebuilder" + >, + log: LogFunction +): FlagFileHandler { + // Check if fetch all flag is active + const isFlagActive = async () => + (await isFlagFileExist(host, FlagFilesOriginal.FETCH_ALL)) || + (await isFlagFileExist(host, FlagFilesHumanReadable.FETCH_ALL)); + + // Cleanup fetch all flag files + const cleanupFlag = async () => { + await deleteFlagFile(host, log, FlagFilesOriginal.FETCH_ALL); + await deleteFlagFile(host, log, FlagFilesHumanReadable.FETCH_ALL); + }; + + // Handle the fetch all scheduled operation + const onScheduled = async () => { + const method = await host.services.UI.dialogManager.openWithExplicitCancel(FetchEverything); + if (method === "cancelled") { + log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE); + await cleanupFlag(); + host.services.appLifecycle.performRestart(); + return false; + } + const { vault, extra } = method; + const settings = await host.services.setting.currentSettings(); + // If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending). + const makeLocalChunkBeforeSyncAvailable = settings.remoteType !== REMOTE_MINIO; + const mapVaultStateToAction = { + identical: { + makeLocalChunkBeforeSync: makeLocalChunkBeforeSyncAvailable, + makeLocalFilesBeforeSync: false, + }, + independent: { + makeLocalChunkBeforeSync: false, + makeLocalFilesBeforeSync: false, + }, + unbalanced: { + makeLocalChunkBeforeSync: false, + makeLocalFilesBeforeSync: true, + }, + cancelled: { + makeLocalChunkBeforeSync: false, + makeLocalFilesBeforeSync: false, + }, + } as const; + + return await processVaultInitialisation(host, log, async () => { + const settings = host.services.setting.currentSettings(); + await adjustSettingToRemoteIfNeeded(host, log, extra, settings); + const vaultStateToAction = mapVaultStateToAction[vault]; + const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction; + log( + `Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`, + LOG_LEVEL_INFO + ); + await host.serviceModules.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync); + await cleanupFlag(); + log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE); + return true; + }); + }; + + return { + priority: 10, + check: () => isFlagActive(), + handle: async () => { + const res = await onScheduled(); + if (res) { + return await verifyAndUnlockSuspension(host, log); + } + return false; + }, + }; +} + +/** + * Adjust setting to remote configuration. + * @param config current configuration to retrieve remote preferred config + * @returns updated configuration if applied, otherwise null. + */ +export async function adjustSettingToRemote( + host: NecessaryServices<"tweakValue" | "UI" | "setting", any>, + log: LogFunction, + config: ObsidianLiveSyncSettings +) { + // Fetch remote configuration unless prevented. + const SKIP_FETCH = "Skip and proceed"; + const RETRY_FETCH = "Retry (recommended)"; + let canProceed = false; + do { + const remoteTweaks = await host.services.tweakValue.fetchRemotePreferred(config); + if (!remoteTweaks) { + const choice = await host.services.UI.confirm.askSelectStringDialogue( + "Could not fetch configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.", + [SKIP_FETCH, RETRY_FETCH] as const, + { + defaultAction: RETRY_FETCH, + timeout: 0, + title: "Fetch Remote Configuration Failed", + } + ); + if (choice === SKIP_FETCH) { + canProceed = true; + } + } else { + const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks); + // Check if any necessary tweak value is different from current config. + const differentItems = Object.entries(necessary).filter(([key, value]) => { + return (config as any)[key] !== value; + }); + if (differentItems.length === 0) { + log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE); + } else { + await host.services.UI.confirm.askSelectStringDialogue( + "Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!", + ["OK"] as const, + { + defaultAction: "OK", + timeout: 0, + } + ); + } + + config = { + ...config, + ...Object.fromEntries(differentItems), + } satisfies ObsidianLiveSyncSettings; + await host.services.setting.applyPartial(config, true); + log("Remote configuration applied.", LOG_LEVEL_NOTICE); + canProceed = true; + const updatedConfig = host.services.setting.currentSettings(); + return updatedConfig; + } + } while (!canProceed); +} + +/** + * Adjust setting to remote if needed. + * @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything) + * @param config current configuration to retrieve remote preferred config + */ +export async function adjustSettingToRemoteIfNeeded( + host: NecessaryServices<"tweakValue" | "UI" | "setting", any>, + log: LogFunction, + extra: { preventFetchingConfig: boolean }, + config: ObsidianLiveSyncSettings +) { + if (extra && extra.preventFetchingConfig) { + return; + } + + // Remote configuration fetched and applied. + if (await adjustSettingToRemote(host, log, config)) { + config = host.services.setting.currentSettings(); + } else { + log("Remote configuration not applied.", LOG_LEVEL_NOTICE); + } + log(JSON.stringify(config), LOG_LEVEL_VERBOSE); +} + +/** + * Process vault initialisation with suspending file watching and sync. + * @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process. + * @param keepSuspending whether to keep suspending file watching after the process. + * @returns result of the process, or false if error occurs. + */ +export async function processVaultInitialisation( + host: NecessaryServices<"setting", any>, + log: LogFunction, + proc: () => Promise, + keepSuspending = false +) { + try { + // Disable batch saving and file watching during initialisation. + await host.services.setting.applyPartial({ batchSave: false }, false); + await host.services.setting.suspendAllSync(); + await host.services.setting.suspendExtraSync(); + await host.services.setting.applyPartial({ suspendFileWatching: true }, true); + try { + const result = await proc(); + return result; + } catch (ex) { + log("Error during vault initialisation process.", LOG_LEVEL_NOTICE); + log(ex, LOG_LEVEL_VERBOSE); + return false; + } + } catch (ex) { + log("Error during vault initialisation.", LOG_LEVEL_NOTICE); + log(ex, LOG_LEVEL_VERBOSE); + return false; + } finally { + if (!keepSuspending) { + // Re-enable file watching after initialisation. + await host.services.setting.applyPartial({ suspendFileWatching: false }, true); + } + } +} + +export async function verifyAndUnlockSuspension( + host: NecessaryServices<"setting" | "appLifecycle" | "UI", any>, + log: LogFunction +) { + if (!host.services.setting.currentSettings().suspendFileWatching) { + return true; + } + if ( + (await host.services.UI.confirm.askYesNoDialog( + "Do you want to resume file and database processing, and restart obsidian now?", + { defaultOption: "Yes", timeout: 15 } + )) != "yes" + ) { + // TODO: Confirm actually proceed to next process. + return true; + } + await host.services.setting.applyPartial({ suspendFileWatching: false }, true); + host.services.appLifecycle.performRestart(); + return false; +} + +/** + * Factory function to create a rebuild flag handler. + * All logic related to rebuild flag is encapsulated here. + */ +export function createRebuildFlagHandler( + host: NecessaryServices<"setting" | "appLifecycle" | "UI" | "tweakValue", "storageAccess" | "rebuilder">, + log: LogFunction +) { + // Check if rebuild flag is active + const isFlagActive = async () => + (await isFlagFileExist(host, FlagFilesOriginal.REBUILD_ALL)) || + (await isFlagFileExist(host, FlagFilesHumanReadable.REBUILD_ALL)); + + // Cleanup rebuild flag files + const cleanupFlag = async () => { + await deleteFlagFile(host, log, FlagFilesOriginal.REBUILD_ALL); + await deleteFlagFile(host, log, FlagFilesHumanReadable.REBUILD_ALL); + }; + + // Handle the rebuild everything scheduled operation + const onScheduled = async () => { + const method = await host.services.UI.dialogManager.openWithExplicitCancel(RebuildEverything); + if (method === "cancelled") { + log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE); + await cleanupFlag(); + host.services.appLifecycle.performRestart(); + return false; + } + const { extra } = method; + const settings = host.services.setting.currentSettings(); + await adjustSettingToRemoteIfNeeded(host, log, extra, settings); + return await processVaultInitialisation(host, log, async () => { + await host.serviceModules.rebuilder.$rebuildEverything(); + await cleanupFlag(); + log("Rebuild everything operation completed.", LOG_LEVEL_NOTICE); + return true; + }); + }; + + return { + priority: 20, + check: () => isFlagActive(), + handle: async () => { + const res = await onScheduled(); + if (res) { + return await verifyAndUnlockSuspension(host, log); + } + return false; + }, + }; +} + +/** + * Factory function to create a suspend all flag handler. + * All logic related to suspend flag is encapsulated here. + */ +export function createSuspendFlagHandler( + host: NecessaryServices<"setting", "storageAccess">, + log: LogFunction +): FlagFileHandler { + // Check if suspend flag is active + const isFlagActive = async () => await isFlagFileExist(host, FlagFilesOriginal.SUSPEND_ALL); + + // Handle the suspend all scheduled operation + const onScheduled = async () => { + log("SCRAM is detected. All operations are suspended.", LOG_LEVEL_NOTICE); + return await processVaultInitialisation( + host, + log, + async () => { + log( + "All operations are suspended as per SCRAM.\nLogs will be written to the file. This might be a performance impact.", + LOG_LEVEL_NOTICE + ); + await host.services.setting.applyPartial({ writeLogToTheFile: true }, true); + return Promise.resolve(false); + }, + true + ); + }; + + return { + priority: 5, + check: () => isFlagActive(), + handle: () => onScheduled(), + }; +} + +export function flagHandlerToEventHandler(flagHandler: FlagFileHandler) { + return async () => { + if (await flagHandler.check()) { + return await flagHandler.handle(); + } + return true; + }; +} + +export function useRedFlagFeatures( + host: NecessaryServices< + "API" | "appLifecycle" | "UI" | "setting" | "tweakValue" | "fileProcessing" | "vault", + "storageAccess" | "rebuilder" + > +) { + const log = createInstanceLogFunction("SF:RedFlag", host.services.API); + const handlerFetch = createFetchAllFlagHandler(host, log); + const handlerRebuild = createRebuildFlagHandler(host, log); + const handlerSuspend = createSuspendFlagHandler(host, log); + host.services.appLifecycle.onLayoutReady.addHandler(flagHandlerToEventHandler(handlerFetch), handlerFetch.priority); + host.services.appLifecycle.onLayoutReady.addHandler( + flagHandlerToEventHandler(handlerRebuild), + handlerRebuild.priority + ); + host.services.appLifecycle.onLayoutReady.addHandler( + flagHandlerToEventHandler(handlerSuspend), + handlerSuspend.priority + ); +} diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts new file mode 100644 index 0000000..65fcef4 --- /dev/null +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -0,0 +1,1140 @@ +import { describe, it, expect, vi } from "vitest"; +import type { LogFunction } from "@lib/services/lib/logUtils"; +import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/redflag.const"; +import { REMOTE_MINIO } from "@lib/common/models/setting.const"; +import { + createFetchAllFlagHandler, + createRebuildFlagHandler, + createSuspendFlagHandler, + isFlagFileExist, + deleteFlagFile, + adjustSettingToRemote, + adjustSettingToRemoteIfNeeded, + processVaultInitialisation, + verifyAndUnlockSuspension, + flagHandlerToEventHandler, +} from "./redFlag"; +import { + TweakValuesRecommendedTemplate, + TweakValuesShouldMatchedTemplate, + TweakValuesTemplate, +} from "@/lib/src/common/types"; + +// Mock types and functions +const createLoggerMock = (): LogFunction => { + return vi.fn(); +}; + +const createStorageAccessMock = () => { + const files: Set = new Set(); + return { + files, + isExists: vi.fn((path: string) => Promise.resolve(files.has(path))), + normalisePath: vi.fn((path: string) => path), + delete: vi.fn((path: string, _recursive?: boolean) => { + files.delete(path); + return Promise.resolve(); + }), + getFileNames: vi.fn(() => Array.from(files)), + }; +}; + +const createSettingServiceMock = () => { + const settings: any = { + batchSave: true, + suspendFileWatching: false, + writeLogToTheFile: false, + remoteType: "CouchDB", + }; + return { + settings, + currentSettings: vi.fn(() => settings), + applyPartial: vi.fn((partial: any, _feedback?: boolean) => { + Object.assign(settings, partial); + return Promise.resolve(); + }), + suspendAllSync: vi.fn(() => Promise.resolve()), + suspendExtraSync: vi.fn(() => Promise.resolve()), + }; +}; + +const createAppLifecycleMock = () => { + return { + performRestart: vi.fn(), + onLayoutReady: { + addHandler: vi.fn(), + }, + }; +}; + +const createUIServiceMock = () => { + return { + dialogManager: { + openWithExplicitCancel: vi.fn(), + }, + confirm: { + askSelectStringDialogue: vi.fn(), + askYesNoDialog: vi.fn(), + }, + }; +}; + +const createRebuilderMock = () => { + return { + $fetchLocal: vi.fn(async () => {}), + $rebuildEverything: vi.fn(async () => {}), + }; +}; + +const createTweakValueMock = () => { + return { + fetchRemotePreferred: vi.fn(() => Promise.resolve(null)), + }; +}; + +const createHostMock = () => { + const storageAccessMock = createStorageAccessMock(); + const settingMock = createSettingServiceMock(); + const appLifecycleMock = createAppLifecycleMock(); + const uiMock = createUIServiceMock(); + const rebuilderMock = createRebuilderMock(); + const tweakValueMock = createTweakValueMock(); + + return { + services: { + setting: settingMock, + appLifecycle: appLifecycleMock, + UI: uiMock, + tweakValue: tweakValueMock, + }, + serviceModules: { + storageAccess: storageAccessMock, + rebuilder: rebuilderMock, + }, + mocks: { + storageAccess: storageAccessMock, + setting: settingMock, + appLifecycle: appLifecycleMock, + ui: uiMock, + rebuilder: rebuilderMock, + tweakValue: tweakValueMock, + }, + }; +}; + +describe("Red Flag Feature", () => { + describe("isFlagFileExist", () => { + it("should return true if flag file exists", async () => { + const host = createHostMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + + const result = await isFlagFileExist(host as any, FlagFilesOriginal.FETCH_ALL); + expect(result).toBe(true); + }); + + it("should return false if flag file does not exist", async () => { + const host = createHostMock(); + + const result = await isFlagFileExist(host as any, FlagFilesOriginal.FETCH_ALL); + expect(result).toBe(false); + }); + }); + + describe("deleteFlagFile", () => { + it("should delete flag file if it exists", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + + await deleteFlagFile(host as any, log, FlagFilesOriginal.FETCH_ALL); + + const exists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesOriginal.FETCH_ALL) + ); + expect(exists).toBe(false); + }); + + it("should not throw error if file does not exist", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + await expect(deleteFlagFile(host as any, log, FlagFilesOriginal.FETCH_ALL)).resolves.not.toThrow(); + }); + + it("should log error if deletion fails", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("Delete failed")); + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + + await deleteFlagFile(host as any, log, FlagFilesOriginal.FETCH_ALL); + + expect(log).toHaveBeenCalled(); + }); + }); + + describe("FlagFile Handler Priority", () => { + it("should handle suspend flag with priority 5", () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createSuspendFlagHandler(host as any, log); + expect(handler.priority).toBe(5); + expect(typeof handler.check).toBe("function"); + expect(typeof handler.handle).toBe("function"); + }); + + it("should handle fetch all flag with priority 10", () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createFetchAllFlagHandler(host as any, log); + expect(handler.priority).toBe(10); + expect(typeof handler.check).toBe("function"); + expect(typeof handler.handle).toBe("function"); + }); + + it("should handle rebuild all flag with priority 20", () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createRebuildFlagHandler(host as any, log); + expect(handler.priority).toBe(20); + expect(typeof handler.check).toBe("function"); + expect(typeof handler.handle).toBe("function"); + }); + }); + + describe("Setting adjustment during vault initialisation", () => { + it("should suspend file watching during initialisation", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(false); + + const result = await processVaultInitialisation(host as any, log, () => { + expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true); + return Promise.resolve(true); + }); + + expect(result).toBe(true); + }); + + it("should disable batch save during initialisation", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + expect(host.mocks.setting.currentSettings().batchSave).toBe(true); + + const result = await processVaultInitialisation(host as any, log, () => { + expect(host.mocks.setting.currentSettings().batchSave).toBe(false); + return Promise.resolve(true); + }); + + expect(result).toBe(true); + }); + + it("should suspend all sync operations", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + await processVaultInitialisation(host as any, log, () => { + return Promise.resolve(true); + }); + + expect(host.mocks.setting.suspendAllSync).toHaveBeenCalled(); + expect(host.mocks.setting.suspendExtraSync).toHaveBeenCalled(); + }); + + it("should resume file watching after initialisation completes", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + await processVaultInitialisation( + host as any, + log, + () => { + return Promise.resolve(true); + }, + false + ); + + expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(false); + }); + + it("should keep suspending when keepSuspending is true", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + await processVaultInitialisation( + host as any, + log, + () => { + return Promise.resolve(true); + }, + true + ); + + expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true); + }); + + it("should return false when process fails", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const result = await processVaultInitialisation( + host as any, + log, + () => { + throw new Error("Process failed"); + }, + false + ); + + expect(result).toBe(false); + expect(log).toHaveBeenCalled(); + }); + }); + + describe("Suspend Flag Handler", () => { + it("should write logs to file when suspend flag is detected", () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createSuspendFlagHandler(host as any, log); + + expect(handler.priority).toBe(5); + expect(typeof handler.check).toBe("function"); + expect(typeof handler.handle).toBe("function"); + }); + + it("should keep suspending after initialisation when suspend flag is active", async () => { + const host = createHostMock(); + + const handler = createSuspendFlagHandler(host as any, createLoggerMock()); + + const checkResult = await handler.check(); + expect(typeof checkResult).toBe("boolean"); + }); + + it("should apply writeLogToTheFile setting during suspension", async () => { + const host = createHostMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL); + + const handler = createSuspendFlagHandler(host as any, createLoggerMock()); + const checkResult = await handler.check(); + + expect(checkResult).toBe(true); + }); + }); + + describe("Fetch All Flag Handler", () => { + it("should detect fetch all flag using original filename", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + + const handler = createFetchAllFlagHandler(host as any, log); + const exists = await handler.check(); + + expect(exists).toBe(true); + }); + + it("should detect fetch all flag using human-readable filename", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL); + + const handler = createFetchAllFlagHandler(host as any, log); + const exists = await handler.check(); + + expect(exists).toBe(true); + }); + + it("should clean up both original and human-readable fetch all flag files", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL); + + await deleteFlagFile(host as any, log, FlagFilesOriginal.FETCH_ALL); + await deleteFlagFile(host as any, log, FlagFilesHumanReadable.FETCH_ALL); + + const originalExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesOriginal.FETCH_ALL) + ); + const humanExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesHumanReadable.FETCH_ALL) + ); + + expect(originalExists).toBe(false); + expect(humanExists).toBe(false); + }); + + it("should have priority 10", () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createFetchAllFlagHandler(host as any, log); + expect(handler.priority).toBe(10); + }); + }); + + describe("Rebuild All Flag Handler", () => { + it("should detect rebuild all flag using original filename", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + + const handler = createRebuildFlagHandler(host as any, log); + const exists = await handler.check(); + + expect(exists).toBe(true); + }); + + it("should detect rebuild all flag using human-readable filename", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesHumanReadable.REBUILD_ALL); + + const handler = createRebuildFlagHandler(host as any, log); + const exists = await handler.check(); + + expect(exists).toBe(true); + }); + + it("should clean up both original and human-readable rebuild all flag files", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + host.mocks.storageAccess.files.add(FlagFilesHumanReadable.REBUILD_ALL); + + await deleteFlagFile(host as any, log, FlagFilesOriginal.REBUILD_ALL); + await deleteFlagFile(host as any, log, FlagFilesHumanReadable.REBUILD_ALL); + + const originalExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesOriginal.REBUILD_ALL) + ); + const humanExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesHumanReadable.REBUILD_ALL) + ); + + expect(originalExists).toBe(false); + expect(humanExists).toBe(false); + }); + + it("should have priority 20", () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createRebuildFlagHandler(host as any, log); + expect(handler.priority).toBe(20); + }); + }); + + describe("Flag file cleanup on error", () => { + it("should handle errors during flag file deletion gracefully", async () => { + const host = createHostMock(); + + // Simulate error in delete operation + host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("Delete failed")); + + try { + await host.mocks.storageAccess.delete(FlagFilesOriginal.FETCH_ALL, true); + } catch { + // Error handled + } + + expect(host.mocks.storageAccess.delete).toHaveBeenCalled(); + }); + }); + + describe("Integration: Handler registration on layout ready", () => { + it("should register handlers with correct priorities", () => { + const host = createHostMock(); + + expect(host.services.appLifecycle.onLayoutReady.addHandler).toBeDefined(); + expect(typeof host.services.appLifecycle.onLayoutReady.addHandler).toBe("function"); + }); + }); + + describe("Dialog interaction scenarios", () => { + it("should handle fetch all dialog cancellation", async () => { + const host = createHostMock(); + + // Simulate user clicking cancel + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); + + // The dialog manager would return cancelled + const result = await host.mocks.ui.dialogManager.openWithExplicitCancel(); + expect(result).toBe("cancelled"); + }); + + it("should handle rebuild dialog cancellation", async () => { + const host = createHostMock(); + + // Simulate user clicking cancel + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); + + const result = await host.mocks.ui.dialogManager.openWithExplicitCancel(); + expect(result).toBe("cancelled"); + }); + + it("should handle confirm dialog for remote configuration mismatch", async () => { + const host = createHostMock(); + + await host.mocks.ui.confirm.askSelectStringDialogue("Your settings differed slightly.", ["OK"]); + expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled(); + }); + }); + + describe("Remote configuration adjustment", () => { + it("should skip remote configuration fetch when preventFetchingConfig is true", async () => { + const host = createHostMock(); + const config = { preventFetchingConfig: true } as any; + + await adjustSettingToRemoteIfNeeded( + host as any, + createLoggerMock(), + { preventFetchingConfig: true }, + config + ); + + expect(host.mocks.tweakValue.fetchRemotePreferred).not.toHaveBeenCalled(); + }); + + it("should fetch remote configuration when preventFetchingConfig is false", async () => { + const host = createHostMock(); + const config = { batchSave: true } as any; + + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + batchSave: false, + } as any); + + await adjustSettingToRemoteIfNeeded( + host as any, + createLoggerMock(), + { preventFetchingConfig: false }, + config + ); + + expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalled(); + }); + + const mismatchDetectionKeys = Object.keys(TweakValuesShouldMatchedTemplate); + it.each(mismatchDetectionKeys)( + "should apply remote configuration when available and different:%s", + async (key) => { + const host = createHostMock(); + + const config = { [key]: TweakValuesTemplate[key as keyof typeof TweakValuesTemplate] } as any; + const differentValue = + typeof config[key as keyof typeof config] === "boolean" + ? !config[key as keyof typeof config] + : typeof config[key as keyof typeof config] === "number" + ? (config[key as keyof typeof config] as number) + 1 + : "different"; + const differentConfig = { + [key]: differentValue, + }; + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK"); + + await adjustSettingToRemote(host as any, createLoggerMock(), config); + expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled(); + expect(host.mocks.setting.applyPartial).toHaveBeenCalled(); + } + ); + const mismatchAcceptedKeys = Object.keys(TweakValuesRecommendedTemplate).filter( + (key) => !mismatchDetectionKeys.includes(key) + ); + + it.each(mismatchAcceptedKeys)( + "should apply remote configuration when available and different but acceptable: %s", + async (key) => { + const host = createHostMock(); + + const config = { [key]: TweakValuesTemplate[key as keyof typeof TweakValuesTemplate] } as any; + const differentValue = + typeof config[key as keyof typeof config] === "boolean" + ? !config[key as keyof typeof config] + : typeof config[key as keyof typeof config] === "number" + ? (config[key as keyof typeof config] as number) + 1 + : "different"; + const differentConfig = { + [key]: differentValue, + }; + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK"); + + await adjustSettingToRemote(host as any, createLoggerMock(), config); + + expect(host.mocks.setting.applyPartial).toHaveBeenCalled(); + expect(host.mocks.ui.confirm.askSelectStringDialogue).not.toHaveBeenCalled(); + } + ); + + it("should show dialog when remote fetch fails", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + const config = { batchSave: true } as any; + + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed"); + + await adjustSettingToRemote(host as any, log, config); + + expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled(); + }); + + it("should retry when user selects retry option", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + const config = { batchSave: true } as any; + + host.mocks.tweakValue.fetchRemotePreferred + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ batchSave: false } as any); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry (recommended)"); + + await adjustSettingToRemote(host as any, log, config); + + expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledTimes(2); + }); + + it("should log when no changes needed", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + const config = { batchSave: false } as any; + + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + batchSave: false, + } as any); + + await adjustSettingToRemote(host as any, log, config); + + expect(log).toHaveBeenCalled(); + }); + + it("should handle null extra parameter in adjustSettingToRemoteIfNeeded", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + const config = { batchSave: true } as any; + + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null); + host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed"); + + await adjustSettingToRemoteIfNeeded(host as any, log, null as any, config); + + expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalled(); + }); + }); + + describe("MinIO configuration handling", () => { + it("should not enable makeLocalChunkBeforeSync when remote is MinIO", () => { + const host = createHostMock(); + host.mocks.setting.settings.remoteType = REMOTE_MINIO; + + const settings = host.mocks.setting.currentSettings(); + const isMinIO = settings.remoteType === REMOTE_MINIO; + + expect(isMinIO).toBe(true); + }); + + it("should enable makeLocalChunkBeforeSync for non-MinIO remotes", () => { + const host = createHostMock(); + host.mocks.setting.settings.remoteType = "CouchDB"; + + const settings = host.mocks.setting.currentSettings(); + const isMinIO = settings.remoteType === REMOTE_MINIO; + + expect(isMinIO).toBe(false); + }); + }); + + describe("Suspension unlock verification", () => { + it("should return true when suspension is not active", async () => { + const host = createHostMock(); + + const result = await verifyAndUnlockSuspension(host as any, createLoggerMock()); + expect(result).toBe(true); + }); + + it("should ask for confirmation when suspension is active", async () => { + const host = createHostMock(); + + await host.mocks.setting.applyPartial({ suspendFileWatching: true }); + + host.mocks.ui.confirm.askYesNoDialog.mockResolvedValueOnce("yes"); + + await verifyAndUnlockSuspension(host as any, createLoggerMock()); + + expect(host.mocks.ui.confirm.askYesNoDialog).toHaveBeenCalled(); + }); + + it("should return true when user declines suspension unlock", async () => { + const host = createHostMock(); + + await host.mocks.setting.applyPartial({ suspendFileWatching: true }); + host.mocks.ui.confirm.askYesNoDialog.mockResolvedValueOnce("no"); + + const result = await verifyAndUnlockSuspension(host as any, createLoggerMock()); + + expect(result).toBe(true); + }); + + it("should resume file watching and restart when user accepts", async () => { + const host = createHostMock(); + + await host.mocks.setting.applyPartial({ suspendFileWatching: true }, true); + host.mocks.ui.confirm.askYesNoDialog.mockResolvedValueOnce("yes"); + + await verifyAndUnlockSuspension(host as any, createLoggerMock()); + + expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled(); + }); + }); + + describe("Error handling in vault initialization", () => { + it("should handle errors during initialisation gracefully", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const result = await processVaultInitialisation(host as any, log, () => { + throw new Error("Initialization failed"); + }); + + expect(result).toBe(false); + expect(log).toHaveBeenCalled(); + }); + + it("should track log calls during error conditions", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + await processVaultInitialisation(host as any, log, () => { + throw new Error("Test error"); + }); + + expect(log).toHaveBeenCalled(); + }); + + it("should keep suspension state when error occurs during initialization", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + await processVaultInitialisation( + host as any, + log, + () => { + return Promise.resolve(false); + }, + true + ); + + expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true); + }); + + it("should handle applySetting error in processVaultInitialisation", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.setting.applyPartial.mockRejectedValueOnce(new Error("Apply partial failed")); + + const result = await processVaultInitialisation(host as any, log, () => { + return Promise.resolve(true); + }); + + expect(result).toBe(false); + }); + }); + + describe("Flag file detection with both formats", () => { + it("should detect either original or human-readable fetch all flag", async () => { + const host = createHostMock(); + + // Add only human-readable flag + host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL); + + const humanExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesHumanReadable.FETCH_ALL) + ); + + expect(humanExists).toBe(true); + }); + + it("should detect either original or human-readable rebuild flag", async () => { + const host = createHostMock(); + + // Add only original flag + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + + const originalExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesOriginal.REBUILD_ALL) + ); + + expect(originalExists).toBe(true); + }); + }); + + describe("Handler execution", () => { + it("should execute fetch all handler check method", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + + const handler = createFetchAllFlagHandler(host as any, log); + const result = await handler.check(); + + expect(result).toBe(true); + }); + + it("should execute rebuild handler check method", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + + const handler = createRebuildFlagHandler(host as any, log); + const result = await handler.check(); + + expect(result).toBe(true); + }); + + it("should execute suspend handler check method", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL); + + const handler = createSuspendFlagHandler(host as any, log); + const result = await handler.check(); + + expect(result).toBe(true); + }); + + it("should return false when flag does not exist", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createFetchAllFlagHandler(host as any, log); + const result = await handler.check(); + + expect(result).toBe(false); + }); + + it("should return correct priority for each handler", () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const suspendHandler = createSuspendFlagHandler(host as any, log); + const fetchHandler = createFetchAllFlagHandler(host as any, log); + const rebuildHandler = createRebuildFlagHandler(host as any, log); + + expect(suspendHandler.priority).toBe(5); + expect(fetchHandler.priority).toBe(10); + expect(rebuildHandler.priority).toBe(20); + }); + + it("should handle suspend flag and execute handler", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL); + + const handler = createSuspendFlagHandler(host as any, log); + const checkResult = await handler.check(); + + expect(checkResult).toBe(true); + expect(typeof handler.handle).toBe("function"); + }); + + it("should have handle method for all handlers", () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const suspendHandler = createSuspendFlagHandler(host as any, log); + const fetchHandler = createFetchAllFlagHandler(host as any, log); + const rebuildHandler = createRebuildFlagHandler(host as any, log); + + expect(typeof suspendHandler.handle).toBe("function"); + expect(typeof fetchHandler.handle).toBe("function"); + expect(typeof rebuildHandler.handle).toBe("function"); + }); + }); + + describe("Multiple concurrent operations", () => { + it("should handle multiple flag files existing simultaneously", async () => { + const host = createHostMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL); + + const fetchExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesOriginal.FETCH_ALL) + ); + const rebuildExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesOriginal.REBUILD_ALL) + ); + const suspendExists = await host.mocks.storageAccess.isExists( + host.mocks.storageAccess.normalisePath(FlagFilesOriginal.SUSPEND_ALL) + ); + + expect(fetchExists).toBe(true); + expect(rebuildExists).toBe(true); + expect(suspendExists).toBe(true); + }); + + it("should cleanup all flags when processing completes", async () => { + const host = createHostMock(); + + host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL); + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + + await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL); + await host.mocks.storageAccess.delete(FlagFilesOriginal.FETCH_ALL); + + expect(host.mocks.storageAccess.files.size).toBe(0); + }); + }); + + describe("Setting state transitions", () => { + it("should apply complete state transition for initialization", async () => { + const host = createHostMock(); + + // Initial state + const initialState = { + batchSave: host.mocks.setting.currentSettings().batchSave, + suspendFileWatching: host.mocks.setting.currentSettings().suspendFileWatching, + }; + + // Initialization state + await host.mocks.setting.applyPartial( + { + batchSave: false, + suspendFileWatching: true, + }, + true + ); + + const initState = { + batchSave: host.mocks.setting.currentSettings().batchSave, + suspendFileWatching: host.mocks.setting.currentSettings().suspendFileWatching, + }; + + // Post-initialization state + await host.mocks.setting.applyPartial( + { + batchSave: true, + suspendFileWatching: false, + }, + true + ); + + const finalState = { + batchSave: host.mocks.setting.currentSettings().batchSave, + suspendFileWatching: host.mocks.setting.currentSettings().suspendFileWatching, + }; + + expect(initialState.batchSave).toBe(true); + expect(initialState.suspendFileWatching).toBe(false); + + expect(initState.batchSave).toBe(false); + expect(initState.suspendFileWatching).toBe(true); + + expect(finalState.batchSave).toBe(true); + expect(finalState.suspendFileWatching).toBe(false); + }); + }); + + describe("flagHandlerToEventHandler integration", () => { + it("should return true when flag does not exist", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createFetchAllFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + const result = await eventHandler(); + expect(result).toBe(true); + }); + + it("should execute handle when flag exists and check returns true", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); + + const handler = createFetchAllFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + await eventHandler(); + + // When dialog is cancelled, handle returns false + expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled(); + }); + + it("should return handle result when flag exists", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL); + + const handler = createSuspendFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + const result = await eventHandler(); + + // Suspend handler execution results in false from processVaultInitialisation + expect(typeof result).toBe("boolean"); + }); + + it("should not call handle when check returns false", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const handler = createRebuildFlagHandler(host as any, log); + const handleSpy = vi.spyOn(handler, "handle"); + const eventHandler = flagHandlerToEventHandler(handler); + + const result = await eventHandler(); + + // Check returns false because no rebuild flag exists + expect(handleSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should handle rebuild flag with flagHandlerToEventHandler", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); + + const handler = createRebuildFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + await eventHandler(); + + expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled(); + }); + + it("should propagate errors from handle method", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockRejectedValueOnce(new Error("Dialog failed")); + + const handler = createFetchAllFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + try { + await eventHandler(); + } catch (error) { + expect((error as Error).message).toBe("Dialog failed"); + } + }); + + it("should handle fetchAll flag with flagHandlerToEventHandler identical", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + customChunkSize: 1, + } as any); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ vault: "identical", extra: {} }); + host.mocks.rebuilder.$fetchLocal.mockResolvedValueOnce(); + const handler = createFetchAllFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + await Promise.resolve(eventHandler()); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(host.mocks.rebuilder.$fetchLocal).toHaveBeenCalled(); + + expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled(); + }); + it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + customChunkSize: 1, + } as any); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ extra: {} }); + host.mocks.rebuilder.$rebuildEverything.mockResolvedValueOnce(); + const handler = createRebuildFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + await Promise.resolve(eventHandler()); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalled(); + + expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled(); + }); + + it("should execute all handlers in sequence", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + const suspendHandler = createSuspendFlagHandler(host as any, log); + const fetchHandler = createFetchAllFlagHandler(host as any, log); + const rebuildHandler = createRebuildFlagHandler(host as any, log); + + const suspendEvent = flagHandlerToEventHandler(suspendHandler); + const fetchEvent = flagHandlerToEventHandler(fetchHandler); + const rebuildEvent = flagHandlerToEventHandler(rebuildHandler); + + // All should return true when flags don't exist + expect(await suspendEvent()).toBe(true); + expect(await fetchEvent()).toBe(true); + expect(await rebuildEvent()).toBe(true); + }); + + it("should return false from handle when suspending", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL); + + const handler = createSuspendFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + const result = await eventHandler(); + + // Suspend handler returns false from its handle method + expect(result).toBe(false); + }); + + it("should handle check error gracefully", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + + host.mocks.storageAccess.isExists.mockRejectedValueOnce(new Error("Check failed")); + + const handler = createFetchAllFlagHandler(host as any, log); + const eventHandler = flagHandlerToEventHandler(handler); + + try { + await eventHandler(); + } catch (error) { + expect((error as Error).message).toBe("Check failed"); + } + }); + }); +}); diff --git a/styles.css b/styles.css index 72e7ade..0125ecc 100644 --- a/styles.css +++ b/styles.css @@ -1,13 +1,13 @@ -.op-scrollable .added { +.ls-dialog .added { color: var(--text-on-accent); background-color: var(--text-accent); } -.op-scrollable .normal { +.ls-dialog .normal { color: var(--text-normal); } -.op-scrollable .deleted { +.ls-dialog .deleted { color: var(--text-on-accent); background-color: var(--text-muted); }