Refactor conflict resolution into service features

This commit is contained in:
vorotamoroz committed 2026-09-03 12:35:58 +00:00
1 parent 3b2d5aa5af
commit 6ea906b575
19 files changed
+2322 -1240

No files matched your search

@@ -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";
@@ -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;