mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 18:17:05 +00:00
Improve recovery diagnostics and actions
This commit is contained in:
@@ -13,6 +13,13 @@ export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
export type ConflictResolveModalOptions = {
|
||||
readOnly?: boolean;
|
||||
title?: string;
|
||||
localName?: string;
|
||||
remoteName?: string;
|
||||
};
|
||||
|
||||
export class ConflictResolveModal extends Modal {
|
||||
result: diff_result;
|
||||
filename: FilePathWithPrefix;
|
||||
@@ -25,6 +32,7 @@ export class ConflictResolveModal extends Modal {
|
||||
title: string = "Conflicting changes";
|
||||
|
||||
pluginPickMode: boolean = false;
|
||||
readOnly: boolean = false;
|
||||
localName: string = "Base";
|
||||
remoteName: string = "Conflicted";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
@@ -37,16 +45,22 @@ export class ConflictResolveModal extends Modal {
|
||||
filename: FilePathWithPrefix,
|
||||
diff: diff_result,
|
||||
pluginPickMode?: boolean,
|
||||
remoteName?: string
|
||||
remoteName?: string,
|
||||
options?: ConflictResolveModalOptions
|
||||
) {
|
||||
super(app);
|
||||
this.result = diff;
|
||||
this.filename = filename;
|
||||
this.pluginPickMode = pluginPickMode || false;
|
||||
this.readOnly = options?.readOnly ?? false;
|
||||
if (this.pluginPickMode) {
|
||||
this.title = "Pick a version";
|
||||
this.remoteName = `${remoteName || "Remote"}`;
|
||||
this.localName = "Local";
|
||||
} else if (this.readOnly) {
|
||||
this.title = options?.title ?? "Vault and database revision";
|
||||
this.localName = options?.localName ?? "Vault file";
|
||||
this.remoteName = options?.remoteName ?? "Database revision";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,16 +115,18 @@ export class ConflictResolveModal extends Modal {
|
||||
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);
|
||||
}
|
||||
});
|
||||
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) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
@@ -159,24 +175,31 @@ export class ConflictResolveModal extends Modal {
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
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));
|
||||
});
|
||||
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) {
|
||||
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
|
||||
if (this.readOnly) {
|
||||
actionContainer.createEl("button", { text: "Close" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED));
|
||||
});
|
||||
} else {
|
||||
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
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) {
|
||||
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
});
|
||||
}
|
||||
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
});
|
||||
}
|
||||
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
});
|
||||
if (diffLength > 100 * 1024) {
|
||||
this.diffView.empty();
|
||||
this.diffView.setText("(Too large diff to display)");
|
||||
|
||||
@@ -5,6 +5,8 @@ import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/li
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class App {},
|
||||
Modal: class Modal {
|
||||
createdButtons: string[] = [];
|
||||
|
||||
private createElement(): Record<string, unknown> {
|
||||
const element: Record<string, unknown> = {
|
||||
addClass: vi.fn(),
|
||||
@@ -22,6 +24,14 @@ 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
|
||||
) {
|
||||
this.createdButtons.push(String((_options as { text: unknown }).text));
|
||||
}
|
||||
const child = this.createElement();
|
||||
callback?.(child);
|
||||
return child;
|
||||
@@ -82,4 +92,55 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
expect(previousResult).toBe(CANCELLED);
|
||||
expect(replacementState).toBe("still-open");
|
||||
});
|
||||
|
||||
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",
|
||||
}
|
||||
);
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
expect(modal.createdButtons).toContain("Close");
|
||||
expect(modal.createdButtons).not.toContain("Use Vault file");
|
||||
expect(modal.createdButtons).not.toContain("Use Database revision");
|
||||
expect(modal.createdButtons).not.toContain("Concat both");
|
||||
expect(modal.createdButtons).not.toContain("Not now");
|
||||
modal.close();
|
||||
});
|
||||
|
||||
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 comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
});
|
||||
previous.onOpen();
|
||||
|
||||
comparison.onOpen();
|
||||
const previousState = await Promise.race([
|
||||
previous.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
|
||||
previous.sendResponse(CANCELLED);
|
||||
comparison.close();
|
||||
|
||||
expect(previousState).toBe("still-open");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user