Improve postponed conflict resolution handling

Keep unresolved conflicts visible after Not now, reopen them only on explicit requests, and clear stale warning and dialogue state when a replicated resolution arrives. Add typed provisional English messages, revision-tree regressions, specifications, and a focused Real Obsidian E2E.
This commit is contained in:
vorotamoroz
2026-07-23 02:00:24 +00:00
parent 8d82a3a00c
commit d784799969
17 changed files with 728 additions and 44 deletions
@@ -16,16 +16,10 @@ import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/s
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 { eventHub } from "@/common/events.ts";
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";
declare global {
interface LSEvents {
"conflict-cancelled": FilePathWithPrefix;
}
}
export class ModuleConflictResolver extends AbstractModule {
private async _resolveConflictByDeletingRev(
path: FilePathWithPrefix,
@@ -41,7 +35,7 @@ export class ModuleConflictResolver extends AbstractModule {
);
return MISSING_OR_ERROR;
}
eventHub.emitEvent("conflict-cancelled", path);
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
this._log(
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
LOG_LEVEL_INFO
@@ -131,11 +125,12 @@ export class ModuleConflictResolver extends AbstractModule {
// const filename = filenames[0];
return await serialized(`conflict-resolve:${filename}`, async () => {
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
if (
conflictCheckResult === MISSING_OR_ERROR ||
conflictCheckResult === NOT_CONFLICTED ||
conflictCheckResult === CANCELLED
) {
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;
@@ -161,7 +156,7 @@ export class ModuleConflictResolver extends AbstractModule {
}
}
this._log("[conflict] Manual merge required!");
eventHub.emitEvent("conflict-cancelled", filename);
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
});
}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
AUTO_MERGED,
DEFAULT_SETTINGS,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
@@ -9,6 +10,8 @@ import {
import { ModuleConflictResolver } from "./ModuleConflictResolver";
function createModule(files: FilePathWithPrefix[] = []) {
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const tryAutoMerge = vi.fn();
const core = {
_services: {
API: {
@@ -23,6 +26,7 @@ function createModule(files: FilePathWithPrefix[] = []) {
},
conflict: {
resolveByNewest: vi.fn(async () => true),
resolveByDeletingRevision,
},
},
settings: DEFAULT_SETTINGS,
@@ -32,6 +36,10 @@ function createModule(files: FilePathWithPrefix[] = []) {
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => []),
storeContent: vi.fn(async () => true),
},
localDatabase: {
tryAutoMerge,
},
storageAccess: {
getFileNames: vi.fn(async () => files),
@@ -41,7 +49,7 @@ function createModule(files: FilePathWithPrefix[] = []) {
const module = new ModuleConflictResolver(core);
module._log = vi.fn();
return { module };
return { module, resolveByDeletingRevision, tryAutoMerge };
}
describe("ModuleConflictResolver bulk newest resolution", () => {
@@ -116,3 +124,73 @@ describe("ModuleConflictResolver bulk newest resolution", () => {
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("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");
});
});
@@ -1,6 +1,4 @@
import { type Editor, type MarkdownFileInfo, type MarkdownView } from "@/deps.ts";
import { addIcon } from "@/deps.ts";
import { type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "@/common/translation";
import type { LiveSyncCore } from "@/main.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -22,16 +20,6 @@ export class ModuleObsidianMenu extends AbstractModule {
await this.services.replication.replicate(true);
}).addClass("livesync-ribbon-replicate");
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.services.conflict.queueCheckForIfOpen(file.path as FilePathWithPrefix);
},
});
return Promise.resolve(true);
}
@@ -2,10 +2,12 @@ import { App, Modal } from "@/deps.ts";
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { eventHub } from "@/common/events.ts";
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
import { globalSlipBoard } from "@vrtmrz/livesync-commonlib/compat/bureau/bureau";
export type MergeDialogResult = typeof CANCELLED | typeof LEAVE_TO_SUBSEQUENT | string;
export const POSTPONED = Symbol("postponed");
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
declare global {
interface Slips {
@@ -100,7 +102,7 @@ export class ConflictResolveModal extends Modal {
if (this.offEvent) {
this.offEvent();
}
this.offEvent = eventHub.onEvent("conflict-cancelled", (path) => {
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
@@ -169,7 +171,7 @@ export class ConflictResolveModal extends Modal {
}
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(CANCELLED));
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
});
if (diffLength > 100 * 1024) {
this.diffView.empty();
@@ -9,15 +9,67 @@ import {
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConflictResolveModal } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
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";
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
private async isConflicted(filename: FilePathWithPrefix): Promise<boolean | undefined> {
try {
return (await this.core.databaseFileAccess.getConflictedRevs(filename)).length > 0;
} 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 conflicted = await this.isConflicted(filename);
if (conflicted === false) {
this.postponedConflictEpisodes.delete(filename);
return [];
}
if (conflicted === true || this.postponedConflictEpisodes.has(filename)) {
return [$msg("This file has unresolved conflicts.")];
}
return [];
}
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
if ((await this.isConflicted(filename)) === false) {
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",
@@ -38,10 +90,21 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
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);
@@ -126,8 +189,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
if (target) {
const targetItem = notes.find((e) => e.dispPath == target)!;
await this.services.conflict.queueCheckFor(targetItem.path);
await this.services.conflict.ensureAllProcessed();
await this.requestConflictResolution(targetItem.path);
return true;
}
return false;
@@ -172,6 +234,10 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
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));
});
}
}
@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CANCELLED,
DEFAULT_SETTINGS,
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: [],
};
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(),
queueCheckFor: vi.fn(async () => undefined),
ensureAllProcessed: vi.fn(async () => true),
},
replication: { replicateByEvent: vi.fn(async () => true) },
vault: { getActiveFilePath: vi.fn(() => path) },
path: { getPath: vi.fn(), getPathWithoutPrefix: vi.fn() },
};
const core = {
_services: services,
services,
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
localDatabase: {
getDBEntry: vi.fn(async () => false),
findAllDocs: vi.fn(),
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => conflictedRevisions),
},
};
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([]);
});
});