mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 01:57:05 +00:00
Handle multiple conflict revisions deterministically
This commit is contained in:
@@ -1,27 +1,26 @@
|
||||
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 {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
type diff_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import { globalSlipBoard } from "@vrtmrz/livesync-commonlib/compat/bureau/bureau";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
declare global {
|
||||
interface Slips {
|
||||
"conflict-resolved": typeof CANCELLED | MergeDialogResult;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictResolveModal extends Modal {
|
||||
result: diff_result;
|
||||
filename: string;
|
||||
filename: FilePathWithPrefix;
|
||||
|
||||
response: MergeDialogResult = CANCELLED;
|
||||
isClosed = false;
|
||||
consumed = false;
|
||||
private readonly resultPromise = promiseWithResolvers<MergeDialogResult>();
|
||||
|
||||
title: string = "Conflicting changes";
|
||||
|
||||
@@ -33,7 +32,13 @@ export class ConflictResolveModal extends Modal {
|
||||
diffView!: HTMLDivElement;
|
||||
diffNavIndicator!: HTMLSpanElement;
|
||||
|
||||
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
|
||||
constructor(
|
||||
app: App,
|
||||
filename: FilePathWithPrefix,
|
||||
diff: diff_result,
|
||||
pluginPickMode?: boolean,
|
||||
remoteName?: string
|
||||
) {
|
||||
super(app);
|
||||
this.result = diff;
|
||||
this.filename = filename;
|
||||
@@ -43,9 +48,6 @@ export class ConflictResolveModal extends Modal {
|
||||
this.remoteName = `${remoteName || "Remote"}`;
|
||||
this.localName = "Local";
|
||||
}
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
// if not there, simply be ignored.
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
}
|
||||
|
||||
appendDiffFragment(container: HTMLDivElement, text: string, cls: string) {
|
||||
@@ -96,18 +98,19 @@ export class ConflictResolveModal extends Modal {
|
||||
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
// if not there, simply be ignored.
|
||||
globalSlipBoard.submit("conflict-resolved", this.filename, CANCELLED);
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
// 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) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
@@ -155,21 +158,22 @@ export class ConflictResolveModal extends Modal {
|
||||
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
const actionContainer = contentEl.createDiv("conflict-action-container");
|
||||
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
contentEl.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
|
||||
});
|
||||
if (!this.pluginPickMode) {
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) => {
|
||||
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
});
|
||||
}
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
});
|
||||
@@ -196,12 +200,10 @@ export class ConflictResolveModal extends Modal {
|
||||
return;
|
||||
}
|
||||
this.consumed = true;
|
||||
globalSlipBoard.submit("conflict-resolved", this.filename, this.response);
|
||||
this.resultPromise.resolve(this.response);
|
||||
}
|
||||
|
||||
async waitForResult(): Promise<MergeDialogResult> {
|
||||
await delay(100);
|
||||
const r = await globalSlipBoard.awaitNext("conflict-resolved", this.filename);
|
||||
return r;
|
||||
return await this.resultPromise.promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class App {},
|
||||
Modal: class Modal {
|
||||
private createElement(): Record<string, unknown> {
|
||||
const element: Record<string, unknown> = {
|
||||
addClass: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
appendText: vi.fn(),
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
empty: vi.fn(),
|
||||
querySelector: vi.fn(() => null),
|
||||
querySelectorAll: vi.fn(() => []),
|
||||
scrollIntoView: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
};
|
||||
element.createDiv = vi.fn(() => this.createElement());
|
||||
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
|
||||
const child = this.createElement();
|
||||
callback?.(child);
|
||||
return child;
|
||||
});
|
||||
element.createSpan = vi.fn(() => this.createElement());
|
||||
return element;
|
||||
}
|
||||
|
||||
contentEl = this.createElement();
|
||||
titleEl = {
|
||||
setText: vi.fn(),
|
||||
};
|
||||
|
||||
close() {
|
||||
(this as { onClose?: () => void }).onClose?.();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
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: [],
|
||||
};
|
||||
|
||||
describe("ConflictResolveModal result lifecycle", () => {
|
||||
it("returns a response which closes the dialogue before the caller begins waiting", async () => {
|
||||
const modal = new ConflictResolveModal({} as never, "early-response.md" as FilePathWithPrefix, conflict);
|
||||
|
||||
modal.sendResponse(POSTPONED);
|
||||
const result = await Promise.race([
|
||||
modal.waitForResult(),
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
|
||||
]);
|
||||
|
||||
expect(result).toBe(POSTPONED);
|
||||
});
|
||||
|
||||
it("cancels the previous same-path dialogue without cancelling the replacement", async () => {
|
||||
const filename = "same-path.md" as FilePathWithPrefix;
|
||||
const previous = new ConflictResolveModal({} as never, filename, conflict);
|
||||
const replacement = new ConflictResolveModal({} as never, filename, conflict);
|
||||
previous.onOpen();
|
||||
|
||||
replacement.onOpen();
|
||||
const previousResult = await Promise.race([
|
||||
previous.waitForResult(),
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
|
||||
]);
|
||||
const replacementState = await Promise.race([
|
||||
replacement.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
|
||||
previous.sendResponse(CANCELLED);
|
||||
replacement.sendResponse(CANCELLED);
|
||||
|
||||
expect(previousResult).toBe(CANCELLED);
|
||||
expect(replacementState).toBe("still-open");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user