mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 10:07:05 +00:00
Refactor conflict resolution into service features
This commit is contained in:
@@ -22,8 +22,6 @@ import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/comp
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
@@ -33,6 +31,7 @@ import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils
|
||||
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
|
||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
export interface LiveSyncCoreFeatureViews {
|
||||
@@ -157,8 +156,6 @@ export class LiveSyncBaseCore<
|
||||
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleBasicMenu(this));
|
||||
|
||||
@@ -290,6 +287,7 @@ export class LiveSyncBaseCore<
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useConflictResolutionFeature(this);
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
|
||||
+5
-2
@@ -6,7 +6,6 @@ import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./modules/features/ModuleInteractiveConflictResolver.ts";
|
||||
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
||||
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||
@@ -46,6 +45,8 @@ import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync
|
||||
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
|
||||
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
|
||||
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
|
||||
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
|
||||
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core: LiveSyncCore;
|
||||
@@ -160,7 +161,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
new ModuleObsidianSettingsAsMarkdown(core),
|
||||
new ModuleLog(this, core),
|
||||
new ModuleObsidianDocumentHistory(this, core),
|
||||
new ModuleInteractiveConflictResolver(this, core),
|
||||
new ModuleObsidianGlobalHistory(this, core),
|
||||
// new ModuleDev(this, core),
|
||||
new SetupManager(core), // this should be moved to core?
|
||||
@@ -196,6 +196,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
useOfflineScanner(core);
|
||||
useRedFlagFeatures(core);
|
||||
useCheckRemoteSize(core);
|
||||
useInteractiveConflictResolutionFeature(core, (filename, conflictCheckResult) => {
|
||||
return new ConflictResolveModal(this.app, filename, conflictCheckResult);
|
||||
});
|
||||
const compatibilityReview = useCompatibilityReview(
|
||||
core,
|
||||
createObsidianCompatibilityReviewUi(core.confirm)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { sendValue } from "octagonal-wheels/messagepassing/signal";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
|
||||
export class ModuleConflictChecker extends AbstractModule {
|
||||
async _queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
|
||||
const path = file;
|
||||
if (this.settings.checkConflictOnlyOnOpen) {
|
||||
const af = this.services.vault.getActiveFilePath();
|
||||
if (af && af != path) {
|
||||
this._log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.services.conflict.queueCheckFor(path);
|
||||
}
|
||||
|
||||
async _queueConflictCheck(file: FilePathWithPrefix): Promise<void> {
|
||||
const optionalConflictResult = await this.services.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
} else if (optionalConflictResult === "newer") {
|
||||
// The conflict should be resolved by the newer entry.
|
||||
await this.services.conflict.resolveByNewest(file);
|
||||
} else {
|
||||
this.conflictCheckQueue.enqueue(file);
|
||||
}
|
||||
}
|
||||
|
||||
_waitForAllConflictProcessed(): Promise<boolean> {
|
||||
return this.conflictResolveQueue.waitForAllProcessed();
|
||||
}
|
||||
|
||||
// TODO-> Move to ModuleConflictResolver?
|
||||
conflictResolveQueue = new QueueProcessor(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
const filename = filenames[0];
|
||||
return await this.services.conflict.resolve(filename);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
// No need to limit concurrency to `1` here, subsequent process will handle it,
|
||||
// And, some cases, we do not need to synchronised. (e.g., auto-merge available).
|
||||
// Therefore, limiting global concurrency is performed on resolver with the UI.
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const filename = newEntity;
|
||||
sendValue("cancel-resolve-conflict:" + filename, true);
|
||||
const newQueue = [...queue].filter((e) => e != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
conflictCheckQueue = // First process - Check is the file actually need resolve -
|
||||
new QueueProcessor(
|
||||
(files: FilePathWithPrefix[]) => {
|
||||
const filename = files[0];
|
||||
return Promise.resolve([filename]);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: this.conflictResolveQueue,
|
||||
totalRemainingReactiveSource: this.services.conflict.conflictProcessQueueCount,
|
||||
}
|
||||
);
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.queueCheckForIfOpen.setHandler(this._queueConflictCheckIfOpen.bind(this));
|
||||
services.conflict.queueCheckFor.setHandler(this._queueConflictCheck.bind(this));
|
||||
services.conflict.ensureAllProcessed.setHandler(this._waitForAllConflictProcessed.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleConflictResolver extends AbstractModule {
|
||||
private async _resolveConflictByDeletingRev(
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = "",
|
||||
showNotice = true
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
this._log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
this._log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await this.core.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
this._log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
this._log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
|
||||
this._log(`${path} has been merged automatically`, level);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
|
||||
async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result> {
|
||||
//
|
||||
const ret = await this.localDatabase.tryAutoMerge(path, !this.settings.disableMarkdownAutoMerge);
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// Merged content is coming.
|
||||
// 1. Store the merged content to the storage
|
||||
if (!(await this.core.databaseFileAccess.storeContent(path, p))) {
|
||||
this._log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
return await this.services.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
|
||||
}
|
||||
|
||||
const { rightRev, leftLeaf, rightLeaf } = ret;
|
||||
|
||||
// should be one or more conflicts;
|
||||
if (leftLeaf == false) {
|
||||
// what's going on..
|
||||
this._log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
if (rightLeaf == false) {
|
||||
// A locally unreadable conflict leaf may still be recoverable from another
|
||||
// replica or backup. Keep it visible for explicit repair instead of treating
|
||||
// missing chunks as evidence that the branch is obsolete.
|
||||
this._log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
|
||||
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = this.settings.resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// if (lMtime > rMtime) {
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await this.services.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
|
||||
}
|
||||
// make diff.
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
this._log(`conflict(s) found:${path}`);
|
||||
return {
|
||||
left: leftLeaf,
|
||||
right: rightLeaf,
|
||||
diff: diff,
|
||||
};
|
||||
}
|
||||
|
||||
private async _resolveConflict(filename: FilePathWithPrefix): Promise<void> {
|
||||
// const filename = filenames[0];
|
||||
return await serialized(`conflict-resolve:${filename}`, async () => {
|
||||
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
|
||||
// nothing to do.
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
//auto resolved, but need check again;
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
//Wait for the running replication, if not running replication, run it once.
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
this._log("[conflict] Automatically merged, but we have to check it again");
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return;
|
||||
}
|
||||
if (this.settings.showMergeDialogOnlyOnActive) {
|
||||
const af = this.services.vault.getActiveFilePath();
|
||||
if (af && af != filename) {
|
||||
this._log(
|
||||
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._log("[conflict] Manual merge required!");
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
|
||||
});
|
||||
}
|
||||
|
||||
private async _anyResolveConflictByNewest(filename: FilePathWithPrefix, showNotice = true): Promise<boolean> {
|
||||
const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (currentRev == false) {
|
||||
this._log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await this.core.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (
|
||||
[
|
||||
[currentRev.mtime, currentRev._rev],
|
||||
...(await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev];
|
||||
}
|
||||
return [leaf.mtime, rev];
|
||||
})
|
||||
)),
|
||||
] as [number, string][]
|
||||
).sort((a, b) => {
|
||||
const diff = b[0] - a[0];
|
||||
if (diff == 0) {
|
||||
return a[1].localeCompare(b[1], "en", { numeric: true });
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
// console.warn(mTimeAndRev);
|
||||
this._log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
this._log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await this._resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private async _resolveAllConflictedFilesByNewerOnes() {
|
||||
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
const files = await this.core.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
i++;
|
||||
if (i % 10 === 0)
|
||||
this._log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await this._anyResolveConflictByNewest(file, false);
|
||||
}
|
||||
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.resolveByDeletingRevision.setHandler(this._resolveConflictByDeletingRev.bind(this));
|
||||
services.conflict.resolve.setHandler(this._resolveConflict.bind(this));
|
||||
services.conflict.resolveByNewest.setHandler(this._anyResolveConflictByNewest.bind(this));
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(
|
||||
this._resolveAllConflictedFilesByNewerOnes.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
MISSING_OR_ERROR,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleConflictResolver } from "./ModuleConflictResolver";
|
||||
|
||||
function createModule(files: FilePathWithPrefix[] = []) {
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const tryAutoMerge = vi.fn();
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const resolveByUserInteraction = vi.fn(async () => false);
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
conflict: {
|
||||
resolveByNewest: vi.fn(async () => true),
|
||||
resolveByDeletingRevision,
|
||||
resolveByUserInteraction,
|
||||
queueCheckFor,
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn(() => undefined),
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SETTINGS,
|
||||
fileHandler: {
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => []),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: {
|
||||
tryAutoMerge,
|
||||
},
|
||||
storageAccess: {
|
||||
getFileNames: vi.fn(async () => files),
|
||||
},
|
||||
} as any;
|
||||
Object.defineProperty(core, "services", { get: () => core._services });
|
||||
|
||||
const module = new ModuleConflictResolver(core);
|
||||
module._log = vi.fn();
|
||||
return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge };
|
||||
}
|
||||
|
||||
describe("ModuleConflictResolver bulk newest resolution", () => {
|
||||
it("retains the success notice for a non-bulk newest resolution", async () => {
|
||||
const { module } = createModule();
|
||||
const path = "example.md" as FilePathWithPrefix;
|
||||
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
|
||||
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
|
||||
({
|
||||
_id: "doc-id",
|
||||
_rev: rev ?? "2-current",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: rev ? 1 : 2,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
}) as unknown as MetaEntry
|
||||
);
|
||||
module.core.databaseFileAccess.getConflictedRevs = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["1-old"])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await (module as any)._anyResolveConflictByNewest(path);
|
||||
|
||||
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("logs a successful bulk newest resolution without displaying a notice", async () => {
|
||||
const { module } = createModule();
|
||||
const path = "example.md" as FilePathWithPrefix;
|
||||
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
|
||||
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
|
||||
({
|
||||
_id: "doc-id",
|
||||
_rev: rev ?? "2-current",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: rev ? 1 : 2,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
}) as unknown as MetaEntry
|
||||
);
|
||||
module.core.databaseFileAccess.getConflictedRevs = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["1-old"])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await (module as any)._anyResolveConflictByNewest(path, false);
|
||||
|
||||
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_INFO);
|
||||
});
|
||||
|
||||
it("updates notice-level progress once every ten checked files", async () => {
|
||||
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
|
||||
const { module } = createModule(files);
|
||||
const resolveByNewest = vi.spyOn(module as any, "_anyResolveConflictByNewest").mockResolvedValue(true);
|
||||
|
||||
await (module as any)._resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(resolveByNewest).toHaveBeenCalledTimes(11);
|
||||
expect(resolveByNewest).toHaveBeenCalledWith(files[0], false);
|
||||
expect(module._log).toHaveBeenCalledWith(
|
||||
"Check and Processing 10 / 11",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
expect(module._log).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleConflictResolver independent same-path creation", () => {
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
|
||||
function leaf(rev: string, data: string, mtime: number) {
|
||||
return {
|
||||
rev,
|
||||
data,
|
||||
mtime,
|
||||
ctime: mtime,
|
||||
deleted: false,
|
||||
} as any;
|
||||
}
|
||||
|
||||
it("collapses one duplicate revision when independently created files have identical content", async () => {
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledOnce();
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
|
||||
});
|
||||
|
||||
it("returns a manual diff when independently created files have different content", async () => {
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Right content\n", 2000);
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
|
||||
expect(result).toHaveProperty("diff");
|
||||
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleConflictResolver sensible merge hand-off", () => {
|
||||
it("keeps an unreadable non-winner revision unresolved", async () => {
|
||||
const path = "missing-conflict-body.md" as FilePathWithPrefix;
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-unreadable",
|
||||
leftLeaf: {
|
||||
rev: "3-current",
|
||||
data: "Readable current body\n",
|
||||
ctime: 1,
|
||||
mtime: 3,
|
||||
deleted: false,
|
||||
},
|
||||
rightLeaf: false,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the merged body and removes the resolved conflict leaf", async () => {
|
||||
const path = "sensible.md" as FilePathWithPrefix;
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
result: "Title\nLeft changed\nRight changed\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(
|
||||
path,
|
||||
"Title\nLeft changed\nRight changed\n"
|
||||
);
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
|
||||
});
|
||||
|
||||
it("commits a sensible pair before rechecking the remaining manual pair", async () => {
|
||||
const path = "three-versions.md" as FilePathWithPrefix;
|
||||
const { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge } =
|
||||
createModule();
|
||||
const remainingManualPair = {
|
||||
leftRev: "3-merged",
|
||||
rightRev: "2-third",
|
||||
leftLeaf: { rev: "3-merged", data: "Merged\n", ctime: 1, mtime: 3 },
|
||||
rightLeaf: { rev: "2-third", data: "Overlapping\n", ctime: 1, mtime: 2 },
|
||||
};
|
||||
tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
result: "Merged\n",
|
||||
conflictedRev: "2-second",
|
||||
})
|
||||
.mockResolvedValueOnce(remainingManualPair);
|
||||
|
||||
await (module as any)._resolveConflict(path);
|
||||
|
||||
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
|
||||
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
|
||||
await (module as any)._resolveConflict(path);
|
||||
|
||||
expect(tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(resolveByUserInteraction).toHaveBeenCalledWith(
|
||||
path,
|
||||
expect.objectContaining({
|
||||
left: remainingManualPair.leftLeaf,
|
||||
right: remainingManualPair.rightLeaf,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
type diff_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
|
||||
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
export { POSTPONED, type MergeDialogResult };
|
||||
|
||||
export type ConflictResolveModalOptions = {
|
||||
readOnly?: boolean;
|
||||
@@ -35,7 +34,8 @@ export class ConflictResolveModal extends Modal {
|
||||
readOnly: boolean = false;
|
||||
localName: string = "Base";
|
||||
remoteName: string = "Conflicted";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
offConflictCancelled?: ReturnType<typeof eventHub.onEvent>;
|
||||
offPluginUnloaded?: ReturnType<typeof eventHub.onceEvent>;
|
||||
currentDiffIndex = -1;
|
||||
diffView!: HTMLDivElement;
|
||||
diffNavIndicator!: HTMLSpanElement;
|
||||
@@ -112,16 +112,19 @@ export class ConflictResolveModal extends Modal {
|
||||
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.offConflictCancelled?.();
|
||||
this.offConflictCancelled = undefined;
|
||||
this.offPluginUnloaded?.();
|
||||
this.offPluginUnloaded = eventHub.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
this.sendResponse(CANCELLED);
|
||||
});
|
||||
if (!this.readOnly) {
|
||||
// Cancel an older dialogue for this path before subscribing this
|
||||
// instance. Emitting after subscription would close the replacement
|
||||
// itself; the instance-owned result promise then completes the older
|
||||
// caller even when it only begins waiting after this event.
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
this.offConflictCancelled = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
@@ -216,9 +219,10 @@ export class ConflictResolveModal extends Modal {
|
||||
override onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.offConflictCancelled?.();
|
||||
this.offConflictCancelled = undefined;
|
||||
this.offPluginUnloaded?.();
|
||||
this.offPluginUnloaded = undefined;
|
||||
if (this.consumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
+43
-22
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
|
||||
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class App {},
|
||||
@@ -24,12 +25,7 @@ vi.mock("@/deps.ts", () => ({
|
||||
};
|
||||
element.createDiv = vi.fn(() => this.createElement());
|
||||
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
|
||||
if (
|
||||
_tag === "button" &&
|
||||
typeof _options === "object" &&
|
||||
_options !== null &&
|
||||
"text" in _options
|
||||
) {
|
||||
if (_tag === "button" && typeof _options === "object" && _options !== null && "text" in _options) {
|
||||
this.createdButtons.push(String((_options as { text: unknown }).text));
|
||||
}
|
||||
const child = this.createElement();
|
||||
@@ -93,23 +89,50 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
expect(replacementState).toBe("still-open");
|
||||
});
|
||||
|
||||
it("closes for an external resolution of the same file and ignores other files", async () => {
|
||||
const filename = "resolved-elsewhere.md" as FilePathWithPrefix;
|
||||
const modal = new ConflictResolveModal({} as never, filename, conflict);
|
||||
modal.onOpen();
|
||||
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, "other.md" as FilePathWithPrefix);
|
||||
const stateAfterOtherFile = await Promise.race([
|
||||
modal.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
|
||||
await expect(modal.waitForResult()).resolves.toBe(CANCELLED);
|
||||
expect(stateAfterOtherFile).toBe("still-open");
|
||||
});
|
||||
|
||||
it("closes and completes its result when the plug-in unloads", async () => {
|
||||
const modal = new ConflictResolveModal(
|
||||
{} as never,
|
||||
"open-during-unload.md" as FilePathWithPrefix,
|
||||
conflict
|
||||
);
|
||||
modal.onOpen();
|
||||
|
||||
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
const result = await Promise.race([
|
||||
modal.waitForResult(),
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 25)),
|
||||
]);
|
||||
modal.sendResponse(CANCELLED);
|
||||
|
||||
expect(result).toBe(CANCELLED);
|
||||
});
|
||||
|
||||
it("renders a read-only comparison with no resolution actions", () => {
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal & { createdButtons: string[] };
|
||||
const modal = new ReadOnlyModal(
|
||||
{},
|
||||
"repair-preview.md",
|
||||
conflict,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
}
|
||||
);
|
||||
const modal = new ReadOnlyModal({}, "repair-preview.md", conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
});
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
@@ -124,9 +147,7 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
|
||||
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
|
||||
const previous = new ConflictResolveModal({} as never, filename, conflict);
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal;
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (...args: unknown[]) => ConflictResolveModal;
|
||||
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConflictResolveModal, POSTPONED } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
import { displayRev } from "@/common/utils.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
|
||||
private async getConflictVersionCount(filename: FilePathWithPrefix): Promise<number | undefined> {
|
||||
try {
|
||||
const conflictCount = (await this.core.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async getActiveConflictMessages(): Promise<string[]> {
|
||||
const filename = this.services.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await this.getConflictVersionCount(filename);
|
||||
if (versionCount === 0) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
}
|
||||
if (versionCount !== undefined && versionCount >= 3) {
|
||||
return [
|
||||
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
|
||||
COUNT: `${versionCount}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (versionCount === 2 || this.postponedConflictEpisodes.has(filename)) {
|
||||
return [$msg("This file has unresolved conflicts.")];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
|
||||
if ((await this.getConflictVersionCount(filename)) === 0) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
}
|
||||
|
||||
private async requestConflictResolution(filename: FilePathWithPrefix): Promise<void> {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
await this.services.conflict.ensureAllProcessed();
|
||||
}
|
||||
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void this.requestConflictResolution(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
name: "Pick a file to resolve conflict",
|
||||
callback: async () => {
|
||||
await this.pickFileForResolve();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-all-conflictcheck",
|
||||
name: "Resolve all conflicted files",
|
||||
callback: async () => {
|
||||
await this.allConflictCheck();
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean> {
|
||||
// UI for resolving conflicts should one-by-one.
|
||||
return await serialized(`conflict-resolve-ui`, async () => {
|
||||
if (this.postponedConflictEpisodes.has(filename)) {
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
return false;
|
||||
}
|
||||
this._log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
|
||||
const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult);
|
||||
dialog.open();
|
||||
const selected = await dialog.waitForResult();
|
||||
if (selected === POSTPONED) {
|
||||
this.postponedConflictEpisodes.add(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (selected === CANCELLED) {
|
||||
// Cancelled by UI, or another conflict.
|
||||
this._log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
const testDoc = await this.localDatabase.getDBEntry(filename, { conflicts: true }, false, true, true);
|
||||
if (testDoc === false) {
|
||||
this._log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
this._log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await this.refreshConflictState(filename);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
testDoc._rev !== conflictCheckResult.left.rev ||
|
||||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
|
||||
) {
|
||||
this._log(
|
||||
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await this.refreshConflictState(filename);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
const toDelete = selected;
|
||||
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
|
||||
if (toDelete === LEAVE_TO_SUBSEQUENT) {
|
||||
// Concatenate both conflicted revisions.
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = conflictCheckResult.right.rev;
|
||||
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
|
||||
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
if (
|
||||
(await this.services.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
this._log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
typeof toDelete === "string" &&
|
||||
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
|
||||
) {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (
|
||||
(await this.services.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${String(toDelete)})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// In here, some merge has been processed.
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
// And, check it again.
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
async allConflictCheck() {
|
||||
let notifyIfEmpty = true;
|
||||
while (await this.pickFileForResolve(notifyIfEmpty)) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
}
|
||||
|
||||
async pickFileForResolve(notifyIfEmpty = true) {
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({
|
||||
id: doc._id,
|
||||
path: this.getPath(doc),
|
||||
dispPath: this.getPathWithoutPrefix(doc),
|
||||
mtime: doc.mtime,
|
||||
});
|
||||
}
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
if (notifyIfEmpty) {
|
||||
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await this.requestConflictResolution(targetItem.path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async _allScanStat(): Promise<boolean> {
|
||||
const notes: { path: string; mtime: number }[] = [];
|
||||
this._log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: this.getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
this.core.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
|
||||
(anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(() => this.allConflictCheck());
|
||||
});
|
||||
}
|
||||
);
|
||||
this._log(
|
||||
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
for (const note of notes) {
|
||||
this._log(`Conflicted: ${note.path}`);
|
||||
}
|
||||
} else {
|
||||
this._log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} catch (e) {
|
||||
this._log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this.getActiveConflictMessages.bind(this));
|
||||
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
|
||||
eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
|
||||
fireAndForget(() => this.refreshConflictState(filename));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
DEFAULT_SETTINGS,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const modalState = vi.hoisted(() => ({
|
||||
constructed: 0,
|
||||
result: undefined as unknown,
|
||||
postponed: Symbol("postponed"),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
displayRev: (revision: string) => revision,
|
||||
}));
|
||||
|
||||
vi.mock("./InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
POSTPONED: modalState.postponed,
|
||||
ConflictResolveModal: class ConflictResolveModal {
|
||||
constructor() {
|
||||
modalState.constructed++;
|
||||
}
|
||||
|
||||
open() {}
|
||||
|
||||
async waitForResult() {
|
||||
return modalState.result;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./ModuleInteractiveConflictResolver.ts";
|
||||
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
async function* documents(items: unknown[]) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
function createModule(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const handlers = {
|
||||
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: {
|
||||
addHandler: vi.fn((handler: () => Promise<string[]>) => {
|
||||
handlers.unresolvedMessages = handler;
|
||||
}),
|
||||
},
|
||||
onScanningStartupIssues: { addHandler: vi.fn() },
|
||||
onInitialise: { addHandler: vi.fn() },
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
conflict: {
|
||||
resolveByUserInteraction: { addHandler: vi.fn() },
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => conflictedRevisions),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
confirm: {
|
||||
askSelectString: vi.fn(async (): Promise<string | undefined> => undefined),
|
||||
},
|
||||
};
|
||||
const plugin = { app: {} };
|
||||
const module = new ModuleInteractiveConflictResolver(plugin as never, core as never);
|
||||
module._log = vi.fn();
|
||||
return { core, handlers, module, services };
|
||||
}
|
||||
|
||||
describe("ModuleInteractiveConflictResolver postponement", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
|
||||
const { module } = createModule();
|
||||
modalState.result = CANCELLED;
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
|
||||
const { module, services } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await (module as any).requestConflictResolution(path);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(services.conflict.ensureAllProcessed).toHaveBeenCalledOnce();
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { module } = createModule(conflictedRevisions);
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.splice(0);
|
||||
await (module as any).refreshConflictState(path);
|
||||
conflictedRevisions.push("4-later");
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||
const { core, handlers, module, services } = createModule();
|
||||
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
expect(services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
});
|
||||
|
||||
it("removes the active warning once the conflict has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
conflictedRevisions.splice(0);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the number of live versions and reduces it after each resolved pair", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([
|
||||
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
|
||||
]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await (module as any).refreshConflictState(path);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await (module as any).refreshConflictState(path);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const firstSession = createModule(conflictedRevisions);
|
||||
|
||||
await firstSession.module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.shift();
|
||||
|
||||
const restartedSession = createModule(conflictedRevisions);
|
||||
restartedSession.module.onBindFunction(restartedSession.core as never, restartedSession.services as never);
|
||||
await expect(restartedSession.handlers.unresolvedMessages?.()).resolves.toEqual([
|
||||
"This file has unresolved conflicts.",
|
||||
]);
|
||||
|
||||
await restartedSession.module._anyResolveConflictByUI(path, {
|
||||
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
|
||||
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
});
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
|
||||
const { core, module, services } = createModule(["2-unrelated", "2-right"]);
|
||||
modalState.result = LEAVE_TO_SUBSEQUENT;
|
||||
core.localDatabase.getDBEntry.mockResolvedValue({
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-unrelated", "2-right"],
|
||||
});
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "");
|
||||
expect(services.conflict.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
});
|
||||
|
||||
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
||||
const { core, module, services } = createModule(["2-other"]);
|
||||
modalState.result = "2-right";
|
||||
core.localDatabase.getDBEntry.mockResolvedValue({
|
||||
_rev: "3-new-winner",
|
||||
_conflicts: ["2-other"],
|
||||
});
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleInteractiveConflictResolver file selection", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
|
||||
const { core, module } = createModule();
|
||||
core.localDatabase.findAllDocs
|
||||
.mockImplementationOnce(() =>
|
||||
documents([
|
||||
{
|
||||
_id: "note-id",
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-right"],
|
||||
path,
|
||||
mtime: 2,
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => documents([]));
|
||||
core.confirm.askSelectString.mockResolvedValue(path);
|
||||
|
||||
await module.allConflictCheck();
|
||||
|
||||
expect(core.confirm.askSelectString).toHaveBeenCalledOnce();
|
||||
expect(module._log).not.toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module.pickFileForResolve();
|
||||
|
||||
expect(module._log).toHaveBeenCalledTimes(1);
|
||||
expect(module._log).toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IConflictService, IVaultService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
|
||||
export type ConflictCheckingSettings = Pick<ObsidianLiveSyncSettings, "checkConflictOnlyOnOpen">;
|
||||
|
||||
export interface ConflictCheckingDependencies {
|
||||
readonly conflict: Pick<
|
||||
IConflictService,
|
||||
"getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest"
|
||||
>;
|
||||
readonly conflictProcessQueueCount: ReactiveSource<number>;
|
||||
readonly currentSettings: () => ConflictCheckingSettings;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface ConflictCheckingHandlers {
|
||||
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly ensureAllProcessed: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create conflict-checking handlers and retain both queue processors privately.
|
||||
* The returned operations do not expose queue state to a host or consumer.
|
||||
*/
|
||||
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
|
||||
const conflictResolveQueue = new QueueProcessor<FilePathWithPrefix, void>(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
const filename = filenames[0];
|
||||
return await dependencies.conflict.resolve(filename);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
// No need to limit concurrency to `1` here, subsequent process will handle it,
|
||||
// and some cases do not need to be synchronised (for example, auto-merge).
|
||||
// Global concurrency is limited by the resolver with the UI.
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const newQueue = [...queue].filter((entry) => entry != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
const conflictCheckQueue = new QueueProcessor<FilePathWithPrefix, FilePathWithPrefix>(
|
||||
(files: FilePathWithPrefix[]) => {
|
||||
const filename = files[0];
|
||||
return Promise.resolve([filename]);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: conflictResolveQueue,
|
||||
totalRemainingReactiveSource: dependencies.conflictProcessQueueCount,
|
||||
}
|
||||
);
|
||||
|
||||
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
const path = file;
|
||||
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
|
||||
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
|
||||
if (activeFile && activeFile != path) {
|
||||
dependencies.log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await dependencies.conflict.queueCheckFor(path);
|
||||
};
|
||||
|
||||
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
} else if (optionalConflictResult === "newer") {
|
||||
// The conflict should be resolved by the newer entry.
|
||||
await dependencies.conflict.resolveByNewest(file);
|
||||
} else {
|
||||
conflictCheckQueue.enqueue(file);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAllProcessed = (): Promise<boolean> => conflictResolveQueue.waitForAllProcessed();
|
||||
|
||||
return {
|
||||
queueCheckForIfOpen,
|
||||
queueCheckFor,
|
||||
ensureAllProcessed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { ConflictResolutionHost } from "./index";
|
||||
import { createConflictResolutionOperations, useConflictResolutionFeature } from "./index";
|
||||
import type { ConflictResolutionOperationsDependencies } from "./operations";
|
||||
|
||||
type ConflictLeaf = {
|
||||
rev: string;
|
||||
data: string;
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
deleted?: boolean;
|
||||
};
|
||||
|
||||
type HarnessOptions = {
|
||||
files?: FilePathWithPrefix[];
|
||||
settings?: Partial<ObsidianLiveSyncSettings>;
|
||||
activeFile?: FilePathWithPrefix;
|
||||
compose?: boolean;
|
||||
};
|
||||
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
const context = createServiceContext();
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
|
||||
const tryAutoMerge = vi.fn();
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => [] as string[]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
};
|
||||
const fileHandler = {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
};
|
||||
const addLog = vi.fn();
|
||||
const storageAccess = {
|
||||
getFileNames: vi.fn(async () => options.files ?? []),
|
||||
};
|
||||
const activeFile = vi.fn(() => options.activeFile);
|
||||
const services = {
|
||||
API: { addLog },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict,
|
||||
context,
|
||||
database: { localDatabase: { tryAutoMerge } },
|
||||
replication: { replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })) },
|
||||
setting: { currentSettings: vi.fn(() => settings) },
|
||||
vault: { getActiveFilePath: activeFile },
|
||||
};
|
||||
const serviceModules = { databaseFileAccess, fileHandler, storageAccess };
|
||||
|
||||
if (options.compose !== false) {
|
||||
useConflictResolutionFeature({ services, serviceModules } as unknown as ConflictResolutionHost);
|
||||
}
|
||||
|
||||
return {
|
||||
addLog,
|
||||
conflict,
|
||||
context,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
services,
|
||||
serviceModules,
|
||||
storageAccess,
|
||||
tryAutoMerge,
|
||||
};
|
||||
}
|
||||
|
||||
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
|
||||
return { rev, data, mtime, ctime: mtime, deleted: false };
|
||||
}
|
||||
|
||||
function metadata(path: FilePathWithPrefix, rev: string, mtime: number): MetaEntry {
|
||||
return {
|
||||
_id: "doc-id",
|
||||
_rev: rev,
|
||||
path,
|
||||
ctime: mtime,
|
||||
mtime,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
} as unknown as MetaEntry;
|
||||
}
|
||||
|
||||
function createOperationsHarness() {
|
||||
const events = { emitEvent: vi.fn() };
|
||||
const tryAutoMerge = vi.fn();
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => [] as string[]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
};
|
||||
const fileHandler = {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
};
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const resolveByUserInteraction = vi.fn(async () => false);
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const dependencies = {
|
||||
events,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
localDatabase: () => ({ tryAutoMerge }),
|
||||
conflict: { queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction },
|
||||
replication: { replicateUnattendedByEvent },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
vault: { getActiveFilePath: vi.fn(() => undefined) },
|
||||
storageAccess: { getFileNames: vi.fn(async () => [] as FilePathWithPrefix[]) },
|
||||
currentSettings: vi.fn(() => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile: false,
|
||||
syncAfterMerge: true,
|
||||
showMergeDialogOnlyOnActive: false,
|
||||
})),
|
||||
log: vi.fn(),
|
||||
} as unknown as ConflictResolutionOperationsDependencies;
|
||||
|
||||
return {
|
||||
...dependencies,
|
||||
dependencies,
|
||||
operations: createConflictResolutionOperations(dependencies),
|
||||
events,
|
||||
tryAutoMerge,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
resolveByDeletingRevision,
|
||||
resolveByUserInteraction,
|
||||
queueCheckFor,
|
||||
replicateUnattendedByEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("conflict resolution serviceFeature", () => {
|
||||
it("keeps resolver operations on narrow collaborators and extension seams", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same.md" as FilePathWithPrefix;
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acquires the active local database for each resolution attempt", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "database-reset.md" as FilePathWithPrefix;
|
||||
const firstTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
|
||||
const replacementTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
|
||||
let activeDatabase = { tryAutoMerge: firstTryAutoMerge };
|
||||
const dependencies = {
|
||||
...harness.dependencies,
|
||||
localDatabase: () => activeDatabase,
|
||||
};
|
||||
const operations = createConflictResolutionOperations(dependencies);
|
||||
|
||||
await operations.checkConflictAndPerformAutoMerge(path);
|
||||
activeDatabase = { tryAutoMerge: replacementTryAutoMerge };
|
||||
await operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(firstTryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(replacementTryAutoMerge).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns a manual diff for independently created files with different content", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Right content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
|
||||
expect(result).toHaveProperty("diff");
|
||||
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores a sensible merge before resolving its conflict leaf", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "sensible.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
result: "Title\nLeft changed\nRight changed\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(
|
||||
path,
|
||||
"Title\nLeft changed\nRight changed\n"
|
||||
);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
|
||||
});
|
||||
|
||||
it("keeps the conflict leaf when sensible merged content cannot be stored", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "failed-sensible.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
result: "Merged content\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
harness.databaseFileAccess.storeContent.mockResolvedValue(false);
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops before emitting or reflecting when conflict revision deletion fails", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "failed-delete.md" as FilePathWithPrefix;
|
||||
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
|
||||
|
||||
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.events.emitEvent).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks a remaining manual pair after committing a sensible merge", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "three-versions.md" as FilePathWithPrefix;
|
||||
const remainingManualPair = {
|
||||
leftRev: "3-merged",
|
||||
rightRev: "2-third",
|
||||
leftLeaf: leaf("3-merged", "Merged\n", 3),
|
||||
rightLeaf: leaf("2-third", "Overlapping\n", 2),
|
||||
};
|
||||
harness.tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
result: "Merged\n",
|
||||
conflictedRev: "2-second",
|
||||
})
|
||||
.mockResolvedValueOnce(remainingManualPair);
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
|
||||
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
|
||||
path,
|
||||
expect.objectContaining({
|
||||
left: remainingManualPair.leftLeaf,
|
||||
right: remainingManualPair.rightLeaf,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("requeues and replicates through collaborators after an automatic merge", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "merged.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED });
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("postpones a manual merge until its file is active when configured", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "inactive.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
const dependencies = {
|
||||
...harness.dependencies,
|
||||
currentSettings: () => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile: false,
|
||||
syncAfterMerge: false,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
}),
|
||||
vault: { getActiveFilePath: () => "other.md" as FilePath },
|
||||
};
|
||||
const operations = createConflictResolutionOperations(dependencies);
|
||||
|
||||
await operations.resolve(path);
|
||||
|
||||
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
expect(dependencies.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Merging process has been postponed"),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels an active same-file dialogue before serialising a repeated resolution", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "repeated.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
let finishDialogue: ((result: boolean) => void) | undefined;
|
||||
harness.resolveByUserInteraction.mockImplementation(
|
||||
async () => await new Promise<boolean>((resolve) => (finishDialogue = resolve))
|
||||
);
|
||||
harness.events.emitEvent.mockImplementation((event, filename) => {
|
||||
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishDialogue) {
|
||||
const finish = finishDialogue;
|
||||
finishDialogue = undefined;
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
const first = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
|
||||
const replacement = harness.operations.resolve(path);
|
||||
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2));
|
||||
expect(harness.events.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
|
||||
finishDialogue?.(false);
|
||||
await Promise.all([first, replacement]);
|
||||
});
|
||||
|
||||
it("passes only the newest waiting same-file resolution to the interactive resolver", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "repeated-three-times.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
let finishFirstDialogue: ((result: boolean) => void) | undefined;
|
||||
harness.resolveByUserInteraction
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise<boolean>((resolve) => (finishFirstDialogue = resolve))
|
||||
)
|
||||
.mockResolvedValue(false);
|
||||
harness.events.emitEvent.mockImplementation((event, filename) => {
|
||||
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishFirstDialogue) {
|
||||
const finish = finishFirstDialogue;
|
||||
finishFirstDialogue = undefined;
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
const first = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
|
||||
const superseded = harness.operations.resolve(path);
|
||||
const replacement = harness.operations.resolve(path);
|
||||
|
||||
await Promise.all([first, superseded, replacement]);
|
||||
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not open a superseded dialogue after conflict inspection completes", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "superseded-during-inspection.md" as FilePathWithPrefix;
|
||||
const manualConflict = {
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
};
|
||||
let finishFirstInspection!: (result: typeof manualConflict) => void;
|
||||
harness.tryAutoMerge
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise<typeof manualConflict>((resolve) => (finishFirstInspection = resolve))
|
||||
)
|
||||
.mockResolvedValue(manualConflict);
|
||||
harness.resolveByUserInteraction.mockResolvedValue(false);
|
||||
|
||||
const superseded = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
|
||||
const replacement = harness.operations.resolve(path);
|
||||
finishFirstInspection(manualConflict);
|
||||
|
||||
await Promise.all([superseded, replacement]);
|
||||
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("registers all conflict service operations during composition", () => {
|
||||
const harness = createHarness({ compose: false });
|
||||
const registrations = [
|
||||
vi.spyOn(harness.conflict.queueCheckForIfOpen, "setHandler"),
|
||||
vi.spyOn(harness.conflict.queueCheckFor, "setHandler"),
|
||||
vi.spyOn(harness.conflict.ensureAllProcessed, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveByDeletingRevision, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolve, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveByNewest, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveAllConflictedFilesByNewerOnes, "setHandler"),
|
||||
];
|
||||
|
||||
useConflictResolutionFeature({
|
||||
services: harness.services,
|
||||
serviceModules: harness.serviceModules,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
|
||||
for (const registration of registrations) {
|
||||
expect(registration).toHaveBeenCalledOnce();
|
||||
expect(registration).toHaveBeenCalledWith(expect.any(Function));
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the active-file gate and deduplicates pending checks for one path", async () => {
|
||||
const path = "postponed.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({
|
||||
activeFile: "other.md" as FilePathWithPrefix,
|
||||
settings: { checkConflictOnlyOnOpen: true },
|
||||
});
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.queueCheckForIfOpen(path);
|
||||
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
|
||||
|
||||
harness.services.vault.getActiveFilePath = vi.fn(() => path);
|
||||
await Promise.all([harness.conflict.queueCheckFor(path), harness.conflict.queueCheckFor(path)]);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
`${path} is conflicted, merging process has been postponed.`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("honours optional conflict handlers before entering the check queue", async () => {
|
||||
const path = "optional.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
const unregisterResolved = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => true);
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
|
||||
unregisterResolved();
|
||||
|
||||
const unregisterNewer = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => "newer");
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
|
||||
unregisterNewer();
|
||||
});
|
||||
|
||||
it("keeps unreadable conflict revisions available for explicit repair", async () => {
|
||||
const path = "missing-conflict-body.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-unreadable",
|
||||
leftLeaf: leaf("3-current", "Readable current body\n", 3),
|
||||
rightLeaf: false,
|
||||
});
|
||||
|
||||
await harness.conflict.resolve(path);
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
`could not read conflicted revision 2-unreadable:${path}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
""
|
||||
);
|
||||
expect(MISSING_OR_ERROR).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves an identical pair, emits cancellation, and rechecks after merging", async () => {
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
const cancelled: FilePathWithPrefix[] = [];
|
||||
harness.context.events.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => cancelled.push(filename));
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
harness.tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.resolve(path);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "1-left");
|
||||
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
|
||||
expect(cancelled).toEqual([path, path]);
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(AUTO_MERGED).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses deterministic revision ordering and suppresses notices during bulk resolution", async () => {
|
||||
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
|
||||
const harness = createHarness({ files });
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(async (path: FilePathWithPrefix, rev?: string) =>
|
||||
metadata(path, rev ?? "2-current", rev ? 1 : 2)
|
||||
);
|
||||
let conflictInspection = 0;
|
||||
harness.databaseFileAccess.getConflictedRevs.mockImplementation(async () =>
|
||||
conflictInspection++ % 2 === 0 ? ["1-old"] : []
|
||||
);
|
||||
|
||||
await harness.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(11);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
"Check and Processing 10 / 11",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("has been merged automatically"),
|
||||
LOG_LEVEL_INFO,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("uses revision identifiers to break newest-resolution timestamp ties", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same-time.md" as FilePathWithPrefix;
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
|
||||
async (filename: FilePathWithPrefix, revision?: string) => metadata(filename, revision ?? "2-3", 1000)
|
||||
);
|
||||
harness.databaseFileAccess.getConflictedRevs
|
||||
.mockResolvedValueOnce(["2-10", "2-2"])
|
||||
.mockResolvedValueOnce(["2-10"])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await harness.operations.resolveByNewest(path);
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(1, path, "2-3");
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(2, path, "2-10");
|
||||
expect(harness.dependencies.log).toHaveBeenLastCalledWith(
|
||||
`${path} has been merged automatically`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createConflictCheckingHandlers } from "./checker";
|
||||
import { createConflictResolutionOperations } from "./operations";
|
||||
|
||||
type ConflictResolutionServices = NecessaryServices<
|
||||
"API" | "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||
"databaseFileAccess" | "fileHandler" | "storageAccess"
|
||||
>;
|
||||
|
||||
export type ConflictResolutionHost = ConflictResolutionServices & {
|
||||
readonly services: ConflictResolutionServices["services"] & {
|
||||
readonly conflict: InjectableConflictService<ServiceContext>;
|
||||
};
|
||||
};
|
||||
|
||||
/** Compose the host-neutral conflict checker and resolver handlers. */
|
||||
export function useConflictResolutionFeature(host: ConflictResolutionHost): void {
|
||||
const { services, serviceModules } = host;
|
||||
const log = createInstanceLogFunction("SF:ConflictResolution", services.API);
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: serviceModules.databaseFileAccess,
|
||||
fileHandler: serviceModules.fileHandler,
|
||||
localDatabase: () => services.database.localDatabase,
|
||||
conflict: services.conflict,
|
||||
replication: services.replication,
|
||||
appLifecycle: services.appLifecycle,
|
||||
vault: services.vault,
|
||||
storageAccess: serviceModules.storageAccess,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
log,
|
||||
});
|
||||
const checking = createConflictCheckingHandlers({
|
||||
conflict: services.conflict,
|
||||
conflictProcessQueueCount: services.conflict.conflictProcessQueueCount,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
vault: services.vault,
|
||||
log,
|
||||
});
|
||||
|
||||
services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen);
|
||||
services.conflict.queueCheckFor.setHandler(checking.queueCheckFor);
|
||||
services.conflict.ensureAllProcessed.setHandler(checking.ensureAllProcessed);
|
||||
services.conflict.resolveByDeletingRevision.setHandler(operations.resolveByDeletingRevision);
|
||||
services.conflict.resolve.setHandler(operations.resolve);
|
||||
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
|
||||
}
|
||||
|
||||
export { createConflictCheckingHandlers } from "./checker";
|
||||
export type { ConflictCheckingDependencies, ConflictCheckingHandlers } from "./checker";
|
||||
export type {
|
||||
ConflictResolutionOperations,
|
||||
ConflictResolutionOperationsDependencies,
|
||||
ConflictResolutionSettings,
|
||||
} from "./operations";
|
||||
export { createConflictResolutionOperations } from "./operations";
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||
import type {
|
||||
IAppLifecycleService,
|
||||
IConflictService,
|
||||
IReplicationService,
|
||||
IVaultService,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { isLockAcquired, serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
export type ConflictResolutionSettings = Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
"disableMarkdownAutoMerge" | "resolveConflictsByNewerFile" | "syncAfterMerge" | "showMergeDialogOnlyOnActive"
|
||||
>;
|
||||
|
||||
export interface ConflictResolutionOperationsDependencies {
|
||||
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
|
||||
readonly databaseFileAccess: Pick<DatabaseFileAccess, "fetchEntryMeta" | "getConflictedRevs" | "storeContent">;
|
||||
readonly fileHandler: Pick<IFileHandler, "deleteRevisionFromDB" | "dbToStorage">;
|
||||
readonly localDatabase: () => Pick<LiveSyncLocalDB, "tryAutoMerge">;
|
||||
readonly conflict: Pick<
|
||||
IConflictService,
|
||||
"queueCheckFor" | "resolveByDeletingRevision" | "resolveByUserInteraction"
|
||||
>;
|
||||
readonly replication: Pick<IReplicationService, "replicateUnattendedByEvent">;
|
||||
readonly appLifecycle: Pick<IAppLifecycleService, "isSuspended">;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly storageAccess: Pick<StorageAccess, "getFileNames">;
|
||||
readonly currentSettings: () => ConflictResolutionSettings;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface ConflictResolutionOperations {
|
||||
readonly resolveByDeletingRevision: (
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle?: string,
|
||||
showNotice?: boolean
|
||||
) => Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED>;
|
||||
readonly checkConflictAndPerformAutoMerge: (path: FilePathWithPrefix) => Promise<diff_check_result>;
|
||||
readonly resolve: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly resolveByNewest: (filename: FilePathWithPrefix, showNotice?: boolean) => Promise<boolean>;
|
||||
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createConflictResolutionOperations(
|
||||
dependencies: ConflictResolutionOperationsDependencies
|
||||
): ConflictResolutionOperations {
|
||||
const latestResolveRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextResolveRequestId = 0;
|
||||
|
||||
const resolveByDeletingRevision = async (
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = "",
|
||||
showNotice = true
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
dependencies.log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
dependencies.log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await dependencies.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
dependencies.log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
dependencies.log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
|
||||
dependencies.log(`${path} has been merged automatically`, level);
|
||||
return AUTO_MERGED;
|
||||
};
|
||||
|
||||
const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise<diff_check_result> => {
|
||||
const ret = await dependencies
|
||||
.localDatabase()
|
||||
.tryAutoMerge(path, !dependencies.currentSettings().disableMarkdownAutoMerge);
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// 1. Store the merged content to the storage.
|
||||
if (!(await dependencies.databaseFileAccess.storeContent(path, p))) {
|
||||
dependencies.log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
// 2. Delete the conflicted revision and reflect the result if all conflicts are gone.
|
||||
return await dependencies.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
|
||||
}
|
||||
|
||||
const { rightRev, leftLeaf, rightLeaf } = ret;
|
||||
|
||||
// Should be one or more conflicts.
|
||||
if (leftLeaf == false) {
|
||||
dependencies.log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
if (rightLeaf == false) {
|
||||
// A locally unreadable conflict leaf may still be recoverable from another
|
||||
// replica or backup. Keep it visible for explicit repair instead of treating
|
||||
// missing chunks as evidence that the branch is obsolete.
|
||||
dependencies.log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
|
||||
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = dependencies.currentSettings().resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// If lMtime > rMtime.
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await dependencies.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
|
||||
}
|
||||
// Make diff.
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
dependencies.log(`conflict(s) found:${path}`);
|
||||
return {
|
||||
left: leftLeaf,
|
||||
right: rightLeaf,
|
||||
diff: diff,
|
||||
};
|
||||
};
|
||||
|
||||
const resolve = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
const requestId = ++nextResolveRequestId;
|
||||
latestResolveRequestByFilename.set(filename, requestId);
|
||||
const serialisationKey = `conflict-resolve:${filename}`;
|
||||
if (isLockAcquired(serialisationKey)) {
|
||||
// A later check for the same file makes any open comparison stale.
|
||||
// Close it before waiting for the current resolver to release the
|
||||
// per-file lock. Dialogues for other paths remain untouched.
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
}
|
||||
return await serialized(serialisationKey, async () => {
|
||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
|
||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
dependencies.log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
|
||||
// Nothing to do.
|
||||
dependencies.log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
// Auto resolved, but need to check again.
|
||||
if (dependencies.currentSettings().syncAfterMerge && !dependencies.appLifecycle.isSuspended()) {
|
||||
// Wait for the running replication, if not running replication, run it once.
|
||||
await dependencies.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
dependencies.log("[conflict] Automatically merged, but we have to check it again");
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return;
|
||||
}
|
||||
if (dependencies.currentSettings().showMergeDialogOnlyOnActive) {
|
||||
const activeFile = dependencies.vault.getActiveFilePath();
|
||||
if (activeFile && activeFile != filename) {
|
||||
dependencies.log(
|
||||
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dependencies.log("[conflict] Manual merge required!");
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
await dependencies.conflict.resolveByUserInteraction(filename, conflictCheckResult);
|
||||
} finally {
|
||||
if (latestResolveRequestByFilename.get(filename) === requestId) {
|
||||
latestResolveRequestByFilename.delete(filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise<boolean> => {
|
||||
const currentRev = await dependencies.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (currentRev == false) {
|
||||
dependencies.log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await dependencies.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (
|
||||
[
|
||||
[currentRev.mtime, currentRev._rev],
|
||||
...(await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await dependencies.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev];
|
||||
}
|
||||
return [leaf.mtime, rev];
|
||||
})
|
||||
)),
|
||||
] as [number, string][]
|
||||
).sort((a, b) => {
|
||||
const diff = b[0] - a[0];
|
||||
if (diff == 0) {
|
||||
return a[1].localeCompare(b[1], "en", { numeric: true });
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
dependencies.log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
dependencies.log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveAllConflictedFilesByNewerOnes = async (): Promise<void> => {
|
||||
dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
const files = await dependencies.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
i++;
|
||||
if (i % 10 === 0)
|
||||
dependencies.log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await resolveByNewest(file, false);
|
||||
}
|
||||
dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
};
|
||||
|
||||
return {
|
||||
resolveByDeletingRevision,
|
||||
checkConflictAndPerformAutoMerge,
|
||||
resolve,
|
||||
resolveByNewest,
|
||||
resolveAllConflictedFilesByNewerOnes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@/common/events.ts";
|
||||
import { createInteractiveConflictResolutionOperations } from "./operations";
|
||||
import type { ConflictResolveDialogueFactory } from "./types";
|
||||
|
||||
export type InteractiveConflictResolutionHost = NecessaryServices<
|
||||
"API" | "UI" | "appLifecycle" | "conflict" | "replication" | "vault" | "database" | "setting" | "path",
|
||||
"databaseFileAccess"
|
||||
>;
|
||||
|
||||
export function useInteractiveConflictResolutionFeature(
|
||||
host: InteractiveConflictResolutionHost,
|
||||
createDialogue: ConflictResolveDialogueFactory
|
||||
): void {
|
||||
const services = host.services;
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: host.serviceModules.databaseFileAccess,
|
||||
localDatabase: () => services.database.localDatabase,
|
||||
confirm: services.UI.confirm,
|
||||
path: services.path,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
conflict: services.conflict,
|
||||
replication: services.replication,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
createDialogue,
|
||||
log: createInstanceLogFunction("SF:InteractiveConflictResolution", services.API),
|
||||
});
|
||||
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(operations.scanStartupIssues);
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void operations.requestConflictResolution(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
services.API.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
name: "Pick a file to resolve conflict",
|
||||
callback: async () => {
|
||||
await operations.pickFileForResolve();
|
||||
},
|
||||
});
|
||||
services.API.addCommand({
|
||||
id: "livesync-all-conflictcheck",
|
||||
name: "Resolve all conflicted files",
|
||||
callback: async () => {
|
||||
await operations.allConflictCheck();
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages);
|
||||
services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction);
|
||||
const offConflictCancelled = services.context.events.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
|
||||
operations.invalidateWaitingResolution(filename);
|
||||
fireAndForget(() => operations.refreshConflictState(filename));
|
||||
});
|
||||
let featureDisposed = false;
|
||||
const dispose = () => {
|
||||
if (featureDisposed) return;
|
||||
featureDisposed = true;
|
||||
// Stop the refresh listener before cancellation so that unloading does
|
||||
// not start a database read which can race with database disposal.
|
||||
offConflictCancelled();
|
||||
operations.dispose();
|
||||
};
|
||||
const offPluginUnloaded = services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose);
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
offPluginUnloaded();
|
||||
dispose();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
export { createInteractiveConflictResolutionOperations } from "./operations";
|
||||
export type {
|
||||
InteractiveConflictResolutionOperations,
|
||||
InteractiveConflictResolutionOperationsDependencies,
|
||||
} from "./operations";
|
||||
export { POSTPONED } from "./types";
|
||||
export type { ConflictResolveDialogue, ConflictResolveDialogueFactory, MergeDialogResult } from "./types";
|
||||
+683
@@ -0,0 +1,683 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
DEFAULT_SETTINGS,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
MISSING_OR_ERROR,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@/common/events";
|
||||
import {
|
||||
createInteractiveConflictResolutionOperations,
|
||||
type InteractiveConflictResolutionOperationsDependencies,
|
||||
} from "./operations";
|
||||
import { POSTPONED, type ConflictResolveDialogueFactory, type MergeDialogResult } from "./types";
|
||||
import { useInteractiveConflictResolutionFeature } from "./index";
|
||||
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
async function* documents(items: unknown[]) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const context = createServiceContext();
|
||||
let dialogueResult: unknown = POSTPONED;
|
||||
const constructed = { value: 0 };
|
||||
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
|
||||
const findAllDocs = vi.fn(() => documents([]));
|
||||
const askSelectString = vi.fn(async (): Promise<string> => "");
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const ensureAllProcessed = vi.fn(async () => true);
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const getConflictedRevs = vi.fn(async () => conflictedRevisions);
|
||||
const storeContent = vi.fn(async () => true);
|
||||
const isSuspended = vi.fn(() => false);
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const currentSettings = vi.fn(() => ({ syncAfterMerge: false }));
|
||||
const log = vi.fn();
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs,
|
||||
storeContent,
|
||||
},
|
||||
localDatabase: () => ({ getDBEntry, findAllDocs }),
|
||||
confirm: {
|
||||
askSelectString,
|
||||
askInPopup: vi.fn(),
|
||||
},
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
appLifecycle: { isSuspended },
|
||||
conflict: { queueCheckFor, ensureAllProcessed, resolveByDeletingRevision },
|
||||
replication: {
|
||||
replicateUnattendedByEvent,
|
||||
},
|
||||
currentSettings,
|
||||
createDialogue: vi.fn(() => {
|
||||
constructed.value++;
|
||||
return {
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async () => dialogueResult),
|
||||
};
|
||||
}),
|
||||
log,
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return {
|
||||
askSelectString,
|
||||
constructed,
|
||||
context,
|
||||
dialogueResult: {
|
||||
get value() {
|
||||
return dialogueResult;
|
||||
},
|
||||
set value(value: unknown) {
|
||||
dialogueResult = value;
|
||||
},
|
||||
},
|
||||
findAllDocs,
|
||||
getDBEntry,
|
||||
getConflictedRevs,
|
||||
ensureAllProcessed,
|
||||
currentSettings,
|
||||
isSuspended,
|
||||
log,
|
||||
operations,
|
||||
queueCheckFor,
|
||||
replicateUnattendedByEvent,
|
||||
resolveByDeletingRevision,
|
||||
storeContent,
|
||||
conflictedRevisions,
|
||||
};
|
||||
}
|
||||
|
||||
type ControlledDialogue = {
|
||||
readonly filename: FilePathWithPrefix;
|
||||
readonly open: ReturnType<typeof vi.fn>;
|
||||
readonly finish: (result: MergeDialogResult) => void;
|
||||
readonly waitForResult: () => Promise<MergeDialogResult>;
|
||||
};
|
||||
|
||||
function createControlledDialogueFactory(
|
||||
context: ReturnType<typeof createServiceContext>,
|
||||
dialogues: ControlledDialogue[]
|
||||
): ConflictResolveDialogueFactory {
|
||||
const createDialogue = vi.fn((filename: FilePathWithPrefix) => {
|
||||
let settle!: (result: MergeDialogResult) => void;
|
||||
let offConflictCancelled: (() => void) | undefined;
|
||||
const result = new Promise<MergeDialogResult>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const finish = (dialogueResult: MergeDialogResult) => {
|
||||
offConflictCancelled?.();
|
||||
offConflictCancelled = undefined;
|
||||
settle(dialogueResult);
|
||||
};
|
||||
const dialogue: ControlledDialogue = {
|
||||
filename,
|
||||
open: vi.fn(() => {
|
||||
offConflictCancelled = context.events.onEvent(EVENT_CONFLICT_CANCELLED, (cancelledFilename) => {
|
||||
if (cancelledFilename === filename) {
|
||||
finish(CANCELLED);
|
||||
}
|
||||
});
|
||||
}),
|
||||
finish,
|
||||
waitForResult: () => result,
|
||||
};
|
||||
dialogues.push(dialogue);
|
||||
return dialogue;
|
||||
});
|
||||
return createDialogue as unknown as ConflictResolveDialogueFactory;
|
||||
}
|
||||
|
||||
function createDialogueConcurrencyHarness() {
|
||||
const context = createServiceContext();
|
||||
const emitEvent = vi.spyOn(context.events, "emitEvent");
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const createDialogue = createControlledDialogueFactory(context, dialogues);
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => ["2-right"]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: () => ({
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
}),
|
||||
confirm: { askSelectString: vi.fn(), askInPopup: vi.fn() },
|
||||
path: { getPath: vi.fn() },
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict: {
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
currentSettings: () => ({ syncAfterMerge: false }),
|
||||
createDialogue: createDialogue as ConflictResolveDialogueFactory,
|
||||
log: vi.fn(),
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return { context, createDialogue, dialogues, emitEvent, operations };
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution operations", () => {
|
||||
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
|
||||
const first = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(1));
|
||||
const superseded = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
const replacement = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(2));
|
||||
expect(fixture.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(fixture.dialogues[1].filename).toBe(path);
|
||||
expect(fixture.createDialogue).toHaveBeenCalledTimes(2);
|
||||
|
||||
fixture.dialogues[1].finish(CANCELLED);
|
||||
await Promise.all([first, superseded, replacement]);
|
||||
});
|
||||
|
||||
it("keeps a different file waiting until the active dialogue finishes", async () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
const otherPath = "other.md" as FilePathWithPrefix;
|
||||
|
||||
const first = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(1));
|
||||
const waiting = fixture.operations.resolveByUserInteraction(otherPath, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.dialogues).toHaveLength(1);
|
||||
expect(fixture.emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, otherPath);
|
||||
|
||||
fixture.dialogues[0].finish(CANCELLED);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(2));
|
||||
expect(fixture.dialogues[1].filename).toBe(otherPath);
|
||||
fixture.dialogues[1].finish(CANCELLED);
|
||||
await Promise.all([first, waiting]);
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { constructed, operations } = createOperations();
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
|
||||
const { constructed, dialogueResult, operations } = createOperations();
|
||||
dialogueResult.value = CANCELLED;
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(2);
|
||||
});
|
||||
|
||||
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
|
||||
const { constructed, dialogueResult, ensureAllProcessed, operations, queueCheckFor } = createOperations();
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.requestConflictResolution(path);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(queueCheckFor).toHaveBeenCalledOnce();
|
||||
expect(ensureAllProcessed).toHaveBeenCalledOnce();
|
||||
expect(constructed.value).toBe(2);
|
||||
expect(dialogueResult.value).toBe(POSTPONED);
|
||||
});
|
||||
|
||||
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { constructed, operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
conflictedRevisions.splice(0);
|
||||
await operations.refreshConflictState(path);
|
||||
conflictedRevisions.push("4-later");
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(2);
|
||||
});
|
||||
|
||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||
const { operations } = createOperations();
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
});
|
||||
|
||||
it("removes the active warning once the conflict has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
conflictedRevisions.splice(0);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the number of live versions and reduces it after each resolved pair", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const { operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([
|
||||
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
|
||||
]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await operations.refreshConflictState(path);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await operations.refreshConflictState(path);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const firstSession = createOperations(conflictedRevisions);
|
||||
|
||||
await firstSession.operations.resolveByUserInteraction(path, conflict);
|
||||
conflictedRevisions.shift();
|
||||
|
||||
const restartedSession = createOperations(conflictedRevisions);
|
||||
await expect(restartedSession.operations.getActiveConflictMessages()).resolves.toEqual([
|
||||
"This file has unresolved conflicts.",
|
||||
]);
|
||||
|
||||
await restartedSession.operations.resolveByUserInteraction(path, {
|
||||
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
|
||||
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
});
|
||||
|
||||
expect(restartedSession.constructed.value).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
|
||||
const fixture = createOperations(["2-unrelated", "2-right"]);
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-unrelated", "2-right"],
|
||||
});
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.storeContent).toHaveBeenCalledWith(path, "");
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
});
|
||||
|
||||
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
||||
const fixture = createOperations(["2-other"]);
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({
|
||||
_rev: "3-new-winner",
|
||||
_conflicts: ["2-other"],
|
||||
});
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("does not delete a revision when concatenated content cannot be stored", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.storeContent.mockResolvedValue(false);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replicate or requeue when selected revision deletion fails", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replicates and requeues after a selected revision is resolved", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Selected");
|
||||
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: expect.anything(),
|
||||
});
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("requeues without replication while the app is suspended", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
fixture.isSuspended.mockReturnValue(true);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.findAllDocs
|
||||
.mockImplementationOnce(() =>
|
||||
documents([
|
||||
{
|
||||
_id: "note-id",
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-right"],
|
||||
path,
|
||||
mtime: 2,
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => documents([]));
|
||||
fixture.askSelectString.mockResolvedValue(path);
|
||||
|
||||
await fixture.operations.allConflictCheck();
|
||||
|
||||
expect(fixture.askSelectString).toHaveBeenCalledOnce();
|
||||
expect(fixture.log).not.toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
|
||||
});
|
||||
|
||||
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
|
||||
const fixture = createOperations();
|
||||
|
||||
await fixture.operations.pickFileForResolve();
|
||||
|
||||
expect(fixture.askSelectString).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
function createFeatureHarness(
|
||||
createDialogueForContext?: (
|
||||
context: ReturnType<typeof createServiceContext>
|
||||
) => ConflictResolveDialogueFactory
|
||||
) {
|
||||
const context = createServiceContext();
|
||||
const initialLocalDatabase = {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
};
|
||||
let activeLocalDatabase = initialLocalDatabase;
|
||||
const getLocalDatabase = vi.fn(() => activeLocalDatabase);
|
||||
const replaceLocalDatabase = (replacement: typeof initialLocalDatabase) => {
|
||||
activeLocalDatabase = replacement;
|
||||
};
|
||||
const database = {} as { readonly localDatabase: ReturnType<typeof getLocalDatabase> };
|
||||
Object.defineProperty(database, "localDatabase", { get: getLocalDatabase });
|
||||
const handlers = {
|
||||
initialise: undefined as undefined | (() => Promise<boolean>),
|
||||
onUnload: undefined as undefined | (() => Promise<boolean>),
|
||||
scanning: undefined as undefined | (() => Promise<boolean>),
|
||||
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
|
||||
resolveByUserInteraction: undefined as
|
||||
| undefined
|
||||
| ((filename: FilePathWithPrefix, result: diff_result) => Promise<boolean>),
|
||||
};
|
||||
const getConflictedRevs = vi.fn(async () => ["2-right"]);
|
||||
const services = {
|
||||
API: {
|
||||
addCommand: vi.fn(),
|
||||
addLog: vi.fn(),
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askSelectString: vi.fn(async () => ""),
|
||||
askInPopup: vi.fn(),
|
||||
},
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: {
|
||||
addHandler: vi.fn((handler: () => Promise<string[]>) => {
|
||||
handlers.unresolvedMessages = handler;
|
||||
}),
|
||||
},
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.initialise = handler;
|
||||
}),
|
||||
},
|
||||
onScanningStartupIssues: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.scanning = handler;
|
||||
}),
|
||||
},
|
||||
onUnload: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.onUnload = handler;
|
||||
}),
|
||||
},
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
conflict: {
|
||||
resolveByUserInteraction: {
|
||||
addHandler: vi.fn(
|
||||
(handler: (filename: FilePathWithPrefix, result: diff_result) => Promise<boolean>) => {
|
||||
handlers.resolveByUserInteraction = handler;
|
||||
}
|
||||
),
|
||||
},
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
database,
|
||||
setting: { currentSettings: vi.fn(() => ({ ...DEFAULT_SETTINGS, syncAfterMerge: false })) },
|
||||
context,
|
||||
};
|
||||
const serviceModules = {
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs,
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
};
|
||||
const createDialogue =
|
||||
createDialogueForContext?.(context) ??
|
||||
vi.fn(() => ({
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async (): Promise<typeof POSTPONED> => POSTPONED),
|
||||
}));
|
||||
|
||||
useInteractiveConflictResolutionFeature({ services, serviceModules } as never, createDialogue);
|
||||
return {
|
||||
context,
|
||||
createDialogue,
|
||||
getConflictedRevs,
|
||||
getLocalDatabase,
|
||||
handlers,
|
||||
initialLocalDatabase,
|
||||
replaceLocalDatabase,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution feature composition", () => {
|
||||
it("registers lifecycle, command, conflict, and cancellation handlers", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
|
||||
expect(fixture.services.appLifecycle.onScanningStartupIssues.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.onInitialise.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.conflict.resolveByUserInteraction.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.getLocalDatabase).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.handlers.initialise?.();
|
||||
expect(fixture.services.API.addCommand).toHaveBeenCalledTimes(3);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ id: "livesync-checkdoc-conflicted", name: "Resolve if conflicted." })
|
||||
);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ id: "livesync-conflictcheck", name: "Pick a file to resolve conflict" })
|
||||
);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ id: "livesync-all-conflictcheck", name: "Resolve all conflicted files" })
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes unresolved state through the host context event channel and disposes the listener", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
|
||||
await fixture.handlers.onUnload?.();
|
||||
fixture.getConflictedRevs.mockClear();
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the active dialogue and drops waiting dialogues on unload", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
|
||||
const active = resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||
const waiting = resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
let completed = false;
|
||||
const allResolutions = Promise.all([active, waiting]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
const completedOnUnload = completed;
|
||||
|
||||
if (!completedOnUnload) {
|
||||
dialogues[0]?.finish(CANCELLED);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
dialogues[1]?.finish(CANCELLED);
|
||||
await allResolutions;
|
||||
}
|
||||
|
||||
expect(completedOnUnload).toBe(true);
|
||||
expect(dialogues).toHaveLength(1);
|
||||
await fixture.handlers.onUnload?.();
|
||||
});
|
||||
|
||||
it("drops a waiting dialogue when its conflict is resolved elsewhere", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
const waitingPath = "resolved-while-waiting.md" as FilePathWithPrefix;
|
||||
|
||||
const active = resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||
const waiting = resolveByUserInteraction(waitingPath, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, waitingPath);
|
||||
dialogues[0].finish(CANCELLED);
|
||||
let completed = false;
|
||||
const allResolutions = Promise.all([active, waiting]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
const completedWithoutOpening = completed;
|
||||
|
||||
if (!completedWithoutOpening) {
|
||||
dialogues[1]?.finish(CANCELLED);
|
||||
await allResolutions;
|
||||
}
|
||||
|
||||
expect(completedWithoutOpening).toBe(true);
|
||||
expect(dialogues).toHaveLength(1);
|
||||
await fixture.handlers.onUnload?.();
|
||||
});
|
||||
|
||||
it("uses the replacement local database for operations after a reset", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
const replacementLocalDatabase = {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
};
|
||||
|
||||
await fixture.handlers.scanning?.();
|
||||
fixture.replaceLocalDatabase(replacementLocalDatabase);
|
||||
await fixture.handlers.scanning?.();
|
||||
|
||||
expect(fixture.initialLocalDatabase.findAllDocs).toHaveBeenCalledOnce();
|
||||
expect(replacementLocalDatabase.findAllDocs).toHaveBeenCalledOnce();
|
||||
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("routes registered command callbacks to conflict operations", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
await fixture.handlers.initialise?.();
|
||||
const commands = fixture.services.API.addCommand.mock.calls.map(
|
||||
([command]) => command as Record<string, unknown>
|
||||
);
|
||||
|
||||
(commands[0].editorCallback as (editor: unknown, view: unknown) => void)({}, { file: { path } });
|
||||
await vi.waitFor(() => expect(fixture.services.conflict.ensureAllProcessed).toHaveBeenCalledOnce());
|
||||
await (commands[1].callback as () => Promise<void>)();
|
||||
await (commands[2].callback as () => Promise<void>)();
|
||||
|
||||
expect(fixture.services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports a failed startup scan without escaping the lifecycle handler", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
async function* failedDocuments() {
|
||||
throw new Error("database unavailable");
|
||||
}
|
||||
fixture.replaceLocalDatabase({
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => failedDocuments()),
|
||||
});
|
||||
|
||||
await expect(fixture.handlers.scanning?.()).resolves.toBe(false);
|
||||
expect(fixture.services.API.addLog).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type {
|
||||
IAppLifecycleService,
|
||||
IConflictService,
|
||||
IPathService,
|
||||
IReplicationService,
|
||||
IVaultService,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
POSTPONED,
|
||||
type ConflictResolveDialogue,
|
||||
type ConflictResolveDialogueFactory,
|
||||
type MergeDialogResult,
|
||||
} from "./types";
|
||||
|
||||
export interface InteractiveConflictResolutionOperationsDependencies {
|
||||
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
|
||||
readonly databaseFileAccess: Pick<DatabaseFileAccess, "getConflictedRevs" | "storeContent">;
|
||||
readonly localDatabase: () => Pick<LiveSyncLocalDB, "getDBEntry" | "findAllDocs">;
|
||||
readonly confirm: Pick<Confirm, "askSelectString" | "askInPopup">;
|
||||
readonly path: Pick<IPathService, "getPath">;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly appLifecycle: Pick<IAppLifecycleService, "isSuspended">;
|
||||
readonly conflict: Pick<IConflictService, "queueCheckFor" | "ensureAllProcessed" | "resolveByDeletingRevision">;
|
||||
readonly replication: Pick<IReplicationService, "replicateUnattendedByEvent">;
|
||||
readonly currentSettings: () => Pick<ObsidianLiveSyncSettings, "syncAfterMerge">;
|
||||
readonly createDialogue: ConflictResolveDialogueFactory;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface InteractiveConflictResolutionOperations {
|
||||
readonly dispose: () => void;
|
||||
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
|
||||
readonly getActiveConflictMessages: () => Promise<string[]>;
|
||||
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly resolveByUserInteraction: (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
) => Promise<boolean>;
|
||||
readonly allConflictCheck: () => Promise<void>;
|
||||
readonly pickFileForResolve: (notifyIfEmpty?: boolean) => Promise<boolean>;
|
||||
readonly scanStartupIssues: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createInteractiveConflictResolutionOperations(
|
||||
dependencies: InteractiveConflictResolutionOperationsDependencies
|
||||
): InteractiveConflictResolutionOperations {
|
||||
// This state deliberately belongs to one feature composition. It must not
|
||||
// survive a plug-in unload or be shared with another host context.
|
||||
const postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
const dialogueSerialisationKey = Symbol("conflict-resolve-ui");
|
||||
const latestRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextRequestId = 0;
|
||||
let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => {
|
||||
// An active dialogue consumes this event itself. Removing its request
|
||||
// here would also remove a same-path replacement which emitted the
|
||||
// event to close that active dialogue. A non-active request is stale
|
||||
// and must not open after an external resolution.
|
||||
if (activeDialogue?.filename === filename) return;
|
||||
latestRequestByFilename.delete(filename);
|
||||
};
|
||||
|
||||
const dispose = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
latestRequestByFilename.clear();
|
||||
postponedConflictEpisodes.clear();
|
||||
const activeFilename = activeDialogue?.filename;
|
||||
if (activeFilename !== undefined) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, activeFilename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
|
||||
try {
|
||||
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveConflictMessages = async (): Promise<string[]> => {
|
||||
const filename = dependencies.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
}
|
||||
if (versionCount !== undefined && versionCount >= 3) {
|
||||
return [
|
||||
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
|
||||
COUNT: `${versionCount}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (versionCount === 2 || postponedConflictEpisodes.has(filename)) {
|
||||
return [$msg("This file has unresolved conflicts.")];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
if ((await getConflictVersionCount(filename)) === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
};
|
||||
|
||||
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
await dependencies.conflict.ensureAllProcessed();
|
||||
};
|
||||
|
||||
const resolveByUserInteraction = async (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
): Promise<boolean> => {
|
||||
if (disposed) return false;
|
||||
const requestId = ++nextRequestId;
|
||||
if (activeDialogue?.filename === filename) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
latestRequestByFilename.set(filename, requestId);
|
||||
|
||||
// UI for resolving different files should proceed one-by-one. A newer
|
||||
// request for the active file replaces its dialogue instead of waiting
|
||||
// behind a comparison which is already stale.
|
||||
return await serialized(dialogueSerialisationKey, async () => {
|
||||
if (disposed || latestRequestByFilename.get(filename) !== requestId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (postponedConflictEpisodes.has(filename)) {
|
||||
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
return false;
|
||||
}
|
||||
dependencies.log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
|
||||
const dialogue = dependencies.createDialogue(filename, conflictCheckResult);
|
||||
activeDialogue = { filename, dialogue };
|
||||
let selected: MergeDialogResult;
|
||||
try {
|
||||
dialogue.open();
|
||||
selected = await dialogue.waitForResult();
|
||||
} finally {
|
||||
if (activeDialogue?.dialogue === dialogue) {
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
}
|
||||
if (selected === POSTPONED) {
|
||||
postponedConflictEpisodes.add(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (selected === CANCELLED) {
|
||||
// Cancelled by UI, or another conflict.
|
||||
dependencies.log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
const testDoc = await dependencies
|
||||
.localDatabase()
|
||||
.getDBEntry(filename, { conflicts: true }, false, true, true);
|
||||
if (testDoc === false) {
|
||||
dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await refreshConflictState(filename);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
testDoc._rev !== conflictCheckResult.left.rev ||
|
||||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
|
||||
) {
|
||||
dependencies.log(
|
||||
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await refreshConflictState(filename);
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
const toDelete = selected;
|
||||
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
|
||||
if (toDelete === LEAVE_TO_SUBSEQUENT) {
|
||||
// Concatenate both conflicted revisions.
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = conflictCheckResult.right.rev;
|
||||
if (!(await dependencies.databaseFileAccess.storeContent(filename, p))) {
|
||||
dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
if (
|
||||
(await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
dependencies.log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
typeof toDelete === "string" &&
|
||||
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
|
||||
) {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (
|
||||
(await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
dependencies.log(
|
||||
`Merge: Something went wrong: ${filename}, (${String(toDelete)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// In here, some merge has been processed.
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (dependencies.currentSettings().syncAfterMerge && !dependencies.appLifecycle.isSuspended()) {
|
||||
await dependencies.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
// And, check it again.
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
} finally {
|
||||
if (latestRequestByFilename.get(filename) === requestId) {
|
||||
latestRequestByFilename.delete(filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const pickFileForResolve = async (notifyIfEmpty = true): Promise<boolean> => {
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({
|
||||
id: doc._id,
|
||||
path: dependencies.path.getPath(doc),
|
||||
dispPath: stripAllPrefixes(dependencies.path.getPath(doc)),
|
||||
mtime: doc.mtime,
|
||||
});
|
||||
}
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
if (notifyIfEmpty) {
|
||||
dependencies.log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await requestConflictResolution(targetItem.path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const allConflictCheck = async (): Promise<void> => {
|
||||
let notifyIfEmpty = true;
|
||||
while (await pickFileForResolve(notifyIfEmpty)) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scanStartupIssues = async (): Promise<boolean> => {
|
||||
const notes: { path: string; mtime: number }[] = [];
|
||||
dependencies.log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
dependencies.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
|
||||
(anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(() => allConflictCheck());
|
||||
});
|
||||
}
|
||||
);
|
||||
dependencies.log(
|
||||
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
for (const note of notes) {
|
||||
dependencies.log(`Conflicted: ${note.path}`);
|
||||
}
|
||||
} else {
|
||||
dependencies.log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} catch (error) {
|
||||
dependencies.log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
dispose,
|
||||
invalidateWaitingResolution,
|
||||
getActiveConflictMessages,
|
||||
refreshConflictState,
|
||||
requestConflictResolution,
|
||||
resolveByUserInteraction,
|
||||
allConflictCheck,
|
||||
pickFileForResolve,
|
||||
scanStartupIssues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
type diff_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
export interface ConflictResolveDialogue {
|
||||
open(): void;
|
||||
waitForResult(): Promise<MergeDialogResult>;
|
||||
}
|
||||
|
||||
export type ConflictResolveDialogueFactory = (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
) => ConflictResolveDialogue;
|
||||
@@ -15,7 +15,10 @@ const taskMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
|
||||
|
||||
import { ModuleConflictResolver } from "@/modules/coreFeatures/ModuleConflictResolver";
|
||||
import {
|
||||
createConflictResolutionOperations,
|
||||
type ConflictResolutionOperationsDependencies,
|
||||
} from "@/serviceFeatures/conflictResolution";
|
||||
import { ModuleObsidianEvents } from "@/modules/essentialObsidian/ModuleObsidianEvents";
|
||||
import {
|
||||
createReplicationSchedulingContext,
|
||||
@@ -224,18 +227,34 @@ describe("automatic replication triggers while P2P is active", () => {
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const path = "merged.md" as FilePathWithPrefix;
|
||||
const module = {
|
||||
settings: p2pSettings({ syncAfterMerge: true }),
|
||||
services: {
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict: { queueCheckFor },
|
||||
replication: { replicateUnattendedByEvent },
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: { emitEvent: vi.fn() },
|
||||
databaseFileAccess: {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => []),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
checkConflictAndPerformAutoMerge: vi.fn(async () => AUTO_MERGED),
|
||||
_log: vi.fn(),
|
||||
};
|
||||
fileHandler: {
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: () => ({
|
||||
tryAutoMerge: vi.fn(async () => ({ ok: AUTO_MERGED })),
|
||||
}),
|
||||
conflict: {
|
||||
queueCheckFor,
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
resolveByUserInteraction: vi.fn(async () => false),
|
||||
},
|
||||
replication: { replicateUnattendedByEvent },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
vault: { getActiveFilePath: vi.fn(() => undefined) },
|
||||
storageAccess: { getFileNames: vi.fn(async () => []) },
|
||||
currentSettings: () => p2pSettings({ syncAfterMerge: true }),
|
||||
log: vi.fn(),
|
||||
} as unknown as ConflictResolutionOperationsDependencies);
|
||||
|
||||
await (ModuleConflictResolver.prototype as any)._resolveConflict.call(module, path);
|
||||
await operations.resolve(path);
|
||||
|
||||
expect(replicateUnattendedByEvent).toHaveBeenCalledOnce();
|
||||
expect(replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
|
||||
Reference in New Issue
Block a user