mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-24 13:32:59 +00:00
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:
@@ -31,7 +31,18 @@ Commit the edited YAML and all regenerated JSON and TypeScript resources togethe
|
||||
|
||||
## Make a message translatable
|
||||
|
||||
1. Add its canonical English entry and translations to `src/common/messagesYAML/`.
|
||||
LiveSync-owned messages may first be added to
|
||||
`src/common/messages/LiveSyncProvisionalMessages.ts` while their wording is being
|
||||
exercised. This keeps an application-only message out of Commonlib and provides a
|
||||
typed English fallback without requiring contributors to update every language.
|
||||
|
||||
When the wording is ready for translation:
|
||||
|
||||
1. Move its canonical English entry from
|
||||
`src/common/messages/LiveSyncProvisionalMessages.ts` to
|
||||
`src/common/messagesYAML/en.yaml`. Remove the provisional entry in the same
|
||||
change. Translations in the other LiveSync YAML files may follow as contributor
|
||||
updates.
|
||||
2. Replace the source literal with `$msg()` or another existing translation helper, using the English catalogue key as the typed contract.
|
||||
3. Run `npm run i18n:bake`, build the plug-in, and verify the affected workflow.
|
||||
|
||||
|
||||
@@ -113,6 +113,8 @@ Self-hosted LiveSync owns the complete multilingual catalogue, generation tools,
|
||||
|
||||
The canonical Commonlib key type and English fallback change with Commonlib. LiveSync may add translations for those keys without duplicating every English definition; its translator delegates absent keys to Commonlib's canonical English fallback. A separate language package remains possible only if independent consumers and release cadence later justify it; core must never depend on an application catalogue.
|
||||
|
||||
LiveSync-owned wording may remain in a typed, application-local provisional English map while it is being exercised. The LiveSync translator composes those keys with the generated application catalogue and the Commonlib key type. Moving a stable message into LiveSync's YAML catalogue makes it available for translation without changing Commonlib.
|
||||
|
||||
### Svelte dialogue hosting
|
||||
|
||||
The present Svelte dialogue implementation is split into three responsibilities:
|
||||
|
||||
@@ -45,6 +45,27 @@ The all-branch history check prevents a resolved conflict from being recreated m
|
||||
|
||||
The compatibility implementation currently selects the newer modification time for differing binary conflicts even when the general **Always overwrite with a newer file** option is disabled. This is existing behaviour, not a new 1.0 guarantee. Changing it to explicit selection only is a separate compatibility decision.
|
||||
|
||||
### Two devices independently create the same path
|
||||
|
||||
If two devices create the same full synchronised path before either device has
|
||||
received the other creation, the two generation-one leaves have no shared
|
||||
revision. Files with the same name in different directories remain separate
|
||||
paths and do not form this conflict.
|
||||
|
||||
When the independently created files contain identical bytes, LiveSync deletes
|
||||
one duplicate leaf without synthesising merged content. A device which still
|
||||
records the deleted duplicate as its displayed revision already has the same
|
||||
bytes as the surviving revision, so it does not recreate the conflict. It
|
||||
rebinds its device-local provenance to the surviving revision.
|
||||
|
||||
When the independently created files contain different bytes, conservative
|
||||
three-way merge has no valid base. LiveSync therefore leaves the two versions
|
||||
for manual selection; it does not guess an empty base or concatenate unrelated
|
||||
files. If both versions instead descend from a revision which the devices had
|
||||
previously synchronised, they are ordinary divergent branches: LiveSync may
|
||||
merge non-overlapping text or structured-data changes from that shared base,
|
||||
and otherwise asks the user.
|
||||
|
||||
## Stale and concurrent resolutions
|
||||
|
||||
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another live leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple live leaves after their trees meet.
|
||||
@@ -76,6 +97,30 @@ When no record exists, LiveSync may reconstruct the displayed revision only if t
|
||||
|
||||
If an edit's base cannot be proved, LiveSync keeps the bytes as another manual-resolution branch instead of attaching them silently to the database winner. If a deletion's displayed branch cannot be proved after the file body has gone, LiveSync preserves every branch and requests conflict review. For an unproven cross-path rename, the new target remains stored and every source branch is preserved for review. These fallbacks can leave a temporary duplicate or unresolved source, but they do not discard an unproven branch.
|
||||
|
||||
## Interactive dialogue policy
|
||||
|
||||
Choosing **Not now** postpones repeated merge dialogues for the same
|
||||
uninterrupted conflict episode in the current plug-in session. Ordinary file
|
||||
checks and replication do not reopen the dialogue while at least one conflict
|
||||
leaf remains. If the in-editor status display is enabled, the active file keeps
|
||||
the warning **This file has unresolved conflicts.** so that postponement does
|
||||
not make the conflict invisible.
|
||||
|
||||
The command **Resolve if conflicted.**, and selecting a file through **Pick a
|
||||
file to resolve conflict**, explicitly clear the postponement and request the
|
||||
dialogue again. Cancellation caused by another conflict dialogue does not count
|
||||
as **Not now**. Once the document has no remaining conflicts, the episode ends;
|
||||
a later conflict at the same path prompts normally. The postponement is not
|
||||
persisted across a plug-in reload.
|
||||
|
||||
When synchronisation supplies a resolved document, the existing incoming-file
|
||||
processing event closes an open conflict dialogue for that path. The same event
|
||||
rechecks the local revision tree: if no conflict leaf remains, it ends any
|
||||
postponed episode and removes the active-file warning. If another conflict leaf
|
||||
still exists, the stale dialogue closes, but the warning remains. A postponed
|
||||
episode stays postponed; otherwise, subsequent conflict processing may open a
|
||||
fresh dialogue for the current revision tree.
|
||||
|
||||
## Example device scenarios
|
||||
|
||||
### A user edits the branch shown on one device
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
|
||||
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
|
||||
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
|
||||
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
|
||||
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
|
||||
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Canonical English for LiveSync-owned messages whose wording is still being
|
||||
* exercised. These keys remain application-owned and must not be added to
|
||||
* Commonlib merely to make them available to the LiveSync translator.
|
||||
*
|
||||
* Move a message to the YAML catalogue when it is ready for translation, and
|
||||
* remove it from this map in the same change.
|
||||
*/
|
||||
export const liveSyncProvisionalEnglishMessages = {
|
||||
"This file has unresolved conflicts.": "This file has unresolved conflicts.",
|
||||
} as const;
|
||||
|
||||
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
|
||||
@@ -65,7 +65,9 @@ export type I18N_LANGS =
|
||||
export type MESSAGE = { [key in I18N_LANGS]?: string };
|
||||
|
||||
import { Logger } from "octagonal-wheels/common/logger";
|
||||
import type { CommonlibMessageKey } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { MessageKeys } from "./messages/combinedMessages.dev.ts";
|
||||
import type { LiveSyncProvisionalMessageKey } from "./messages/LiveSyncProvisionalMessages.ts";
|
||||
|
||||
export function expandKeywords<T extends Record<string, U>, U extends Record<string, string>>(
|
||||
message: T,
|
||||
@@ -114,4 +116,8 @@ export function expandKeywords<T extends Record<string, U>, U extends Record<str
|
||||
return ret as T;
|
||||
}
|
||||
|
||||
export type AllMessageKeys = MessageKeys;
|
||||
/** Keys translated by LiveSync itself, including English-only provisional messages. */
|
||||
export type LiveSyncCatalogueMessageKey = MessageKeys | LiveSyncProvisionalMessageKey;
|
||||
|
||||
/** Keys accepted by the composed LiveSync and Commonlib translation boundary. */
|
||||
export type AllMessageKeys = LiveSyncCatalogueMessageKey | CommonlibMessageKey;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
// Avoid using Obsidian's native function for CLIs.
|
||||
import { getLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { englishMessageTranslator, type MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
commonlibEnglishMessages,
|
||||
englishMessageTranslator,
|
||||
type CommonlibMessageKey,
|
||||
type MessageTranslator,
|
||||
} from "@vrtmrz/livesync-commonlib/context";
|
||||
import { LOG_KIND_WARNING, notice } from "octagonal-wheels/common/logger";
|
||||
import type { TaggedType } from "octagonal-wheels/common/types";
|
||||
import type { AllMessageKeys, I18N_LANGS } from "./rosetta";
|
||||
import type { AllMessageKeys, I18N_LANGS, LiveSyncCatalogueMessageKey } from "./rosetta";
|
||||
import { allMessages } from "./messages/combinedMessages.prod.ts";
|
||||
import {
|
||||
liveSyncProvisionalEnglishMessages,
|
||||
type LiveSyncProvisionalMessageKey,
|
||||
} from "./messages/LiveSyncProvisionalMessages.ts";
|
||||
|
||||
const obsidianLangMap: Record<string, I18N_LANGS> = {
|
||||
de: "de",
|
||||
@@ -58,7 +67,10 @@ export function setLang(lang: I18N_LANGS) {
|
||||
function _getMessage(key: string, lang: I18N_LANGS) {
|
||||
if (key.trim() == "") return key;
|
||||
|
||||
const msgs = allMessages[key] ?? undefined;
|
||||
const provisionalEnglish = liveSyncProvisionalEnglishMessages[key as LiveSyncProvisionalMessageKey];
|
||||
const msgs =
|
||||
allMessages[key] ?? (provisionalEnglish === undefined ? undefined : ({ def: provisionalEnglish } as const));
|
||||
if (msgs === undefined && isCommonlibMessageKey(key)) return englishMessageTranslator(key);
|
||||
const resolvedLang = resolveLanguage(lang);
|
||||
let msg = msgs?.[resolvedLang];
|
||||
|
||||
@@ -87,12 +99,16 @@ export function $t(message: string, lang?: I18N_LANGS) {
|
||||
}
|
||||
|
||||
export function translateIfAvailable(message: string, lang?: I18N_LANGS) {
|
||||
if (message.trim() == "" || allMessages[message] === undefined) return message;
|
||||
if (message.trim() == "" || (!isLiveSyncMessageKey(message) && !isCommonlibMessageKey(message))) return message;
|
||||
return $t(message, lang);
|
||||
}
|
||||
|
||||
function isLiveSyncMessageKey(key: string): key is AllMessageKeys {
|
||||
return key in allMessages;
|
||||
function isCommonlibMessageKey(key: string): key is CommonlibMessageKey {
|
||||
return key in commonlibEnglishMessages;
|
||||
}
|
||||
|
||||
function isLiveSyncMessageKey(key: string): key is LiveSyncCatalogueMessageKey {
|
||||
return key in allMessages || key in liveSyncProvisionalEnglishMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +140,9 @@ export function $msg<T extends AllMessageKeys>(
|
||||
}
|
||||
|
||||
/** Supplies the LiveSync-owned language catalogue through the Commonlib host boundary. */
|
||||
export const translateLiveSyncMessage: MessageTranslator = (key, params) => {
|
||||
if (!isLiveSyncMessageKey(key)) return englishMessageTranslator(key, params);
|
||||
export type LiveSyncMessageTranslator = MessageTranslator<AllMessageKeys>;
|
||||
|
||||
export const translateLiveSyncMessage: LiveSyncMessageTranslator = (key, params) => {
|
||||
if (!isLiveSyncMessageKey(key)) return englishMessageTranslator(key as CommonlibMessageKey, params);
|
||||
return $msg(key, params === undefined ? undefined : { ...params });
|
||||
};
|
||||
|
||||
@@ -25,4 +25,11 @@ describe("LiveSync-owned translation catalogue", () => {
|
||||
|
||||
expect(translateLiveSyncMessage("Active Remote Type")).toBe(englishMessageTranslator("Active Remote Type"));
|
||||
});
|
||||
|
||||
it("uses LiveSync-owned provisional English without extending Commonlib's message contract", () => {
|
||||
expect($msg("This file has unresolved conflicts.")).toBe("This file has unresolved conflicts.");
|
||||
expect(translateLiveSyncMessage("This file has unresolved conflicts.")).toBe(
|
||||
"This file has unresolved conflicts."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other live branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite.
|
||||
|
||||
`test:e2e:obsidian:conflict-dialog-policy` creates real local revision conflicts without a remote service, opens the merge dialogue in Obsidian, and chooses **Not now**. It verifies that an ordinary repeated conflict check does not reopen the dialogue, that the active editor retains the unresolved-conflict warning, and that **Resolve if conflicted.** explicitly reopens the dialogue. It then invokes the same Commonlib consumer boundary used for an incoming replicated document and verifies both resolution-arrival cases: a postponed warning disappears, and an open stale dialogue closes, once the local revision tree has no conflict leaf. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one mobile-safe Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const path = "conflict-dialog-policy.md";
|
||||
const baseContent = "Conflict dialogue policy\n\nShared base.\n";
|
||||
const leftContent = "Conflict dialogue policy\n\nChanged on the left.\n";
|
||||
const rightContent = "Conflict dialogue policy\n\nChanged on the right.\n";
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_CONFLICT_DIALOG_TIMEOUT_MS ?? 10000);
|
||||
|
||||
type ConflictFixture = {
|
||||
currentRev: string;
|
||||
conflicts: string[];
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const content=${JSON.stringify(baseContent)};`,
|
||||
"let file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) file=await app.vault.create(path,content);",
|
||||
"await app.workspace.getLeaf(false).openFile(file);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function createManualConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
baseRev: string
|
||||
): Promise<ConflictFixture> {
|
||||
return await evalObsidianJson<ConflictFixture>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const baseRev=${JSON.stringify(baseRev)};`,
|
||||
`const contents=${JSON.stringify([leftContent, rightContent])};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"for(const content of contents){",
|
||||
" const blob=new Blob([content],{type:'text/plain'});",
|
||||
" const now=Date.now();",
|
||||
" const result=await core.localDatabase.putDBEntry({",
|
||||
" _id:id,path,data:blob,ctime:now,mtime:now,",
|
||||
" size:(await blob.arrayBuffer()).byteLength,children:[],",
|
||||
" datatype:'plain',type:'plain',eden:{},",
|
||||
" },false,baseRev);",
|
||||
" if(!result?.ok) throw new Error(`Could not create conflict branch: ${path}`);",
|
||||
"}",
|
||||
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);",
|
||||
"if(!meta?._rev||!meta._conflicts?.length){",
|
||||
" throw new Error(`Conflict fixture did not produce two live leaves: ${path}`);",
|
||||
"}",
|
||||
"return JSON.stringify({currentRev:meta._rev,conflicts:meta._conflicts});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv, waitForCompletion: boolean) {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const waitForCompletion=${JSON.stringify(waitForCompletion)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const queued=core.services.conflict.queueCheckFor(path);",
|
||||
"if(waitForCompletion){",
|
||||
" await queued;",
|
||||
" await core.services.conflict.ensureAllProcessed();",
|
||||
"}",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function applyReplicatedConflictResolution(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
revisionToDelete: string
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const revisionToDelete=${JSON.stringify(revisionToDelete)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"if(!(await core.fileHandler.deleteRevisionFromDB(path,revisionToDelete))){",
|
||||
" throw new Error(`Could not apply the replicated conflict resolution: ${path} ${revisionToDelete}`);",
|
||||
"}",
|
||||
"const entry=await core.databaseFileAccess.fetchEntryMeta(path,undefined,true);",
|
||||
"if(!entry){",
|
||||
" throw new Error(`Could not read the surviving revision after replicated resolution: ${path}`);",
|
||||
"}",
|
||||
// This is the same Commonlib consumer boundary invoked after a remote
|
||||
// document has already entered the local database. Calling it here
|
||||
// isolates the dialogue policy from transport and second-device setup.
|
||||
"await core.fileHandler._anyProcessReplicatedDoc(entry);",
|
||||
"const conflicts=await core.databaseFileAccess.getConflictedRevs(path);",
|
||||
"if(conflicts.length!==0){",
|
||||
" throw new Error(`Replicated resolution left conflicts behind: ${path} ${JSON.stringify(conflicts)}`);",
|
||||
"}",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
function conflictDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
|
||||
return page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Conflicting changes" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
const cliBinary = cli.binary;
|
||||
|
||||
const vault = await createTemporaryVault("obsidian-livesync-conflict-dialog-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
remoteType: "",
|
||||
couchDB_URI: "http://127.0.0.1:5984",
|
||||
couchDB_DBNAME: "conflict-dialog-policy",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
periodicReplication: false,
|
||||
syncAfterMerge: false,
|
||||
syncOnEditorSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnSave: false,
|
||||
syncOnStart: false,
|
||||
disableMarkdownAutoMerge: true,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
showStatusOnEditor: true,
|
||||
},
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await createAndOpenBaseFile(cliBinary, session.cliEnv);
|
||||
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
const fixture = await createManualConflict(cliBinary, session.cliEnv, base.rev);
|
||||
if (fixture.conflicts.length !== 1) {
|
||||
throw new Error(`Expected exactly two live leaves: ${JSON.stringify(fixture)}`);
|
||||
}
|
||||
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.getByRole("button", { name: "Not now", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
});
|
||||
const firstDialogueScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-before-not-now.png",
|
||||
(page) => conflictDialogue(page).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, true);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const warning = page.locator(".livesync-status-messagearea").filter({
|
||||
hasText: "This file has unresolved conflicts.",
|
||||
});
|
||||
await warning.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (await conflictDialogue(page).isVisible()) {
|
||||
throw new Error("The postponed conflict dialogue reopened during an ordinary conflict check.");
|
||||
}
|
||||
});
|
||||
const warningScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-postponed-warning.png",
|
||||
(page) =>
|
||||
page.locator(".livesync-status-messagearea").filter({
|
||||
hasText: "This file has unresolved conflicts.",
|
||||
})
|
||||
);
|
||||
|
||||
await applyReplicatedConflictResolution(cliBinary, session.cliEnv, fixture.conflicts[0]);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await conflictDialogue(page).waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await page
|
||||
.locator(".livesync-status-messagearea")
|
||||
.filter({ hasText: "This file has unresolved conflicts." })
|
||||
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const resolved = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
const laterFixture = await createManualConflict(cliBinary, session.cliEnv, resolved.rev);
|
||||
if (laterFixture.conflicts.length !== 1) {
|
||||
throw new Error(`Expected a later conflict with exactly two live leaves: ${JSON.stringify(laterFixture)}`);
|
||||
}
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const commandExecuted = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
return await page.evaluate(
|
||||
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:livesync-checkdoc-conflicted"
|
||||
);
|
||||
});
|
||||
if (!commandExecuted) {
|
||||
throw new Error("The explicit conflict-resolution command was not registered for the active editor.");
|
||||
}
|
||||
const activeSession = session;
|
||||
await withObsidianPage(activeSession.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await applyReplicatedConflictResolution(cliBinary, activeSession.cliEnv, laterFixture.conflicts[0]);
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await page
|
||||
.locator(".livesync-status-messagearea")
|
||||
.filter({ hasText: "This file has unresolved conflicts." })
|
||||
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Real Obsidian retained the conflict warning, suppressed an ordinary repeat prompt, reopened the dialogue after the explicit command, and cleared both postponed and open-dialogue states after replicated resolutions."
|
||||
);
|
||||
console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`);
|
||||
console.log(`Postponed warning screenshot: ${warningScreenshot}`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
}
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -16,6 +16,10 @@ Earlier releases remain available in the [0.25 release history](https://github.c
|
||||
|
||||
- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault.
|
||||
|
||||
### Improved
|
||||
|
||||
- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. The existing conflict commands can reopen it explicitly, and a later conflict prompts again after the current one has been resolved.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers.
|
||||
|
||||
Reference in New Issue
Block a user