mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 15:58:19 +00:00
Own conflict cancellation at the feature boundary
This commit is contained in:
1 parent
165944fe65
commit
99d9f4c380
6 files changed
+115
-95
No files matched your search
@@ -17,6 +17,7 @@ export interface ConflictCheckingDependencies {
|
||||
IConflictService,
|
||||
"getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest"
|
||||
>;
|
||||
readonly signal: AbortSignal;
|
||||
readonly conflictProcessQueueCount: ReactiveSource<number>;
|
||||
readonly cancelActiveResolution: (filename: FilePathWithPrefix) => void;
|
||||
readonly currentSettings: () => ConflictCheckingSettings;
|
||||
@@ -25,8 +26,6 @@ export interface ConflictCheckingDependencies {
|
||||
}
|
||||
|
||||
export interface ConflictCheckingHandlers {
|
||||
/** Stop admission and dispatch while allowing started checks to settle. */
|
||||
readonly dispose: () => void;
|
||||
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
|
||||
/** Wait for active checks; false means the queue was cancelled during shutdown. */
|
||||
@@ -35,7 +34,6 @@ export interface ConflictCheckingHandlers {
|
||||
|
||||
/** Create conflict-checking handlers while retaining scheduling state privately. */
|
||||
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
|
||||
let disposed = false;
|
||||
const conflictQueue = createConflictCheckQueue({
|
||||
async process(filename) {
|
||||
try {
|
||||
@@ -55,7 +53,7 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
});
|
||||
|
||||
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
if (disposed) return;
|
||||
if (dependencies.signal.aborted) return;
|
||||
const path = file;
|
||||
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
|
||||
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
|
||||
@@ -68,9 +66,9 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
};
|
||||
|
||||
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
if (disposed) return;
|
||||
if (dependencies.signal.aborted) return;
|
||||
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (disposed) return;
|
||||
if (dependencies.signal.aborted) return;
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
@@ -87,16 +85,18 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
};
|
||||
|
||||
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
|
||||
const dispose = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
const abortQueue = (): void => {
|
||||
// Shutdown keeps its existing database-close order. Observe cleanup
|
||||
// without making the lifecycle wait for an active resolver.
|
||||
void conflictQueue.abort().catch((error) => dependencies.log(error, LOG_LEVEL_VERBOSE));
|
||||
};
|
||||
if (dependencies.signal.aborted) {
|
||||
abortQueue();
|
||||
} else {
|
||||
dependencies.signal.addEventListener("abort", abortQueue, { once: true });
|
||||
}
|
||||
|
||||
return {
|
||||
dispose,
|
||||
queueCheckForIfOpen,
|
||||
queueCheckFor,
|
||||
ensureAllProcessed,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import PouchDB from "pouchdb-core";
|
||||
import MemoryAdapter from "pouchdb-adapter-memory";
|
||||
@@ -23,11 +22,12 @@ describe("conflict-check shutdown with a real PouchDB handle", () => {
|
||||
it.each([false, true])(
|
||||
"reports cancellation and preserves the database outcome when closed before completion: %s",
|
||||
async (closeDuringShutdown) => {
|
||||
const name = `conflict-shutdown-${randomUUID()}`;
|
||||
const name = `conflict-shutdown-${crypto.randomUUID()}`;
|
||||
const db = new PouchDB<Probe>(name, { adapter: "memory" });
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const conflict = new InjectableConflictService(createServiceContext());
|
||||
const lifetime = new AbortController();
|
||||
const log = vi.fn();
|
||||
const resolver = vi.fn(async () => {
|
||||
started.resolve();
|
||||
@@ -38,6 +38,7 @@ describe("conflict-check shutdown with a real PouchDB handle", () => {
|
||||
conflict.resolve.setHandler(resolver);
|
||||
const checking = createConflictCheckingHandlers({
|
||||
conflict,
|
||||
signal: lifetime.signal,
|
||||
conflictProcessQueueCount: conflict.conflictProcessQueueCount,
|
||||
cancelActiveResolution: vi.fn(),
|
||||
currentSettings: () => ({ checkConflictOnlyOnOpen: false }),
|
||||
@@ -53,8 +54,8 @@ describe("conflict-check shutdown with a real PouchDB handle", () => {
|
||||
await checking.queueCheckFor("active.md" as FilePathWithPrefix);
|
||||
await started.promise;
|
||||
completion = checking.ensureAllProcessed();
|
||||
checking.dispose();
|
||||
checking.dispose();
|
||||
lifetime.abort();
|
||||
lifetime.abort();
|
||||
await checking.queueCheckFor("late.md" as FilePathWithPrefix);
|
||||
if (closeDuringShutdown) {
|
||||
await db.close();
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ConflictResolutionHost = NecessaryServices<
|
||||
/** Compose the host-neutral conflict handlers with the host's borrowed logger. */
|
||||
export const useConflictResolutionFeature = createServiceFeature((host: ConflictResolutionHost): void => {
|
||||
const { services, serviceModules, log } = host;
|
||||
const lifetime = new AbortController();
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: serviceModules.databaseFileAccess,
|
||||
@@ -39,6 +40,7 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
vault: services.vault,
|
||||
log,
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
|
||||
services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen);
|
||||
@@ -49,14 +51,10 @@ export const useConflictResolutionFeature = createServiceFeature((host: Conflict
|
||||
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
|
||||
|
||||
const eventSubscriptions = new AbortController();
|
||||
const dispose = (): void => {
|
||||
checking.dispose();
|
||||
eventSubscriptions.abort();
|
||||
};
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
|
||||
const stop = (): void => lifetime.abort();
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal });
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
dispose();
|
||||
stop();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,10 @@ export function useInteractiveConflictResolutionFeature(
|
||||
createDialogue: ConflictResolveDialogueFactory
|
||||
): void {
|
||||
const services = host.services;
|
||||
const lifetime = new AbortController();
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
signal: lifetime.signal,
|
||||
databaseFileAccess: host.serviceModules.databaseFileAccess,
|
||||
localDatabase: () => services.database.localDatabase,
|
||||
confirm: services.UI.confirm,
|
||||
@@ -61,24 +63,18 @@ export function useInteractiveConflictResolutionFeature(
|
||||
});
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages);
|
||||
services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction);
|
||||
const eventSubscriptions = new AbortController();
|
||||
const dispose = () => {
|
||||
// Stop the refresh listener before cancellation so that unloading does
|
||||
// not start a database read which can race with database disposal.
|
||||
eventSubscriptions.abort();
|
||||
operations.dispose();
|
||||
};
|
||||
services.context.events.onEvent(
|
||||
EVENT_CONFLICT_CANCELLED,
|
||||
(filename) => {
|
||||
operations.invalidateWaitingResolution(filename);
|
||||
fireAndForget(() => operations.refreshConflictState(filename));
|
||||
},
|
||||
{ signal: eventSubscriptions.signal }
|
||||
{ signal: lifetime.signal }
|
||||
);
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
|
||||
const stop = (): void => lifetime.abort();
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, stop, { signal: lifetime.signal });
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
dispose();
|
||||
stop();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
+23
-17
@@ -37,6 +37,7 @@ function createOperations(
|
||||
overrides: Partial<InteractiveConflictResolutionOperationsDependencies> = {}
|
||||
) {
|
||||
const context = createServiceContext();
|
||||
const lifetime = new AbortController();
|
||||
let dialogueResult: unknown = POSTPONED;
|
||||
const constructed = { value: 0 };
|
||||
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
|
||||
@@ -55,6 +56,7 @@ function createOperations(
|
||||
const log = vi.fn();
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
signal: overrides.signal ?? lifetime.signal,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs,
|
||||
storeContent,
|
||||
@@ -83,6 +85,7 @@ function createOperations(
|
||||
...overrides,
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return {
|
||||
abort: () => lifetime.abort(),
|
||||
askSelectString,
|
||||
askInPopup,
|
||||
constructed,
|
||||
@@ -154,11 +157,13 @@ function createControlledDialogueFactory(
|
||||
|
||||
function createDialogueConcurrencyHarness() {
|
||||
const context = createServiceContext();
|
||||
const lifetime = new AbortController();
|
||||
const emitEvent = vi.spyOn(context.events, "emitEvent");
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const createDialogue = createControlledDialogueFactory(context, dialogues);
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
signal: lifetime.signal,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => ["2-right"]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
@@ -183,7 +188,7 @@ function createDialogueConcurrencyHarness() {
|
||||
createDialogue: createDialogue as ConflictResolveDialogueFactory,
|
||||
log: vi.fn(),
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return { context, createDialogue, dialogues, emitEvent, operations };
|
||||
return { abort: () => lifetime.abort(), context, createDialogue, dialogues, emitEvent, operations };
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution operations", () => {
|
||||
@@ -218,7 +223,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
const settled = Promise.allSettled([active, waiting]);
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
unregisterLog();
|
||||
finish(result);
|
||||
|
||||
@@ -256,7 +261,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishRead(false);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
@@ -288,7 +293,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.getDBEntry).toHaveBeenCalledOnce());
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishRead({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
@@ -316,7 +321,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishStore(false);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
@@ -351,7 +356,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
await vi.waitFor(() => expect(fixture.storeContent).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishStore(true);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
@@ -383,7 +388,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishDeletion(MISSING_OR_ERROR);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
@@ -417,7 +422,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishDeletion(AUTO_MERGED);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
@@ -447,7 +452,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
rejectInspection(new Error("database unavailable"));
|
||||
|
||||
await expect(refreshing).resolves.toBeUndefined();
|
||||
@@ -483,7 +488,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishInspection(["2-right"]);
|
||||
|
||||
await expect(refreshing).resolves.toBeUndefined();
|
||||
@@ -503,7 +508,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
|
||||
const messages = fixture.operations.getActiveConflictMessages();
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishInspection(["2-right"]);
|
||||
|
||||
await expect(messages).resolves.toEqual([]);
|
||||
@@ -512,7 +517,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
it("does not inspect or publish conflict state when called after disposal", async () => {
|
||||
const fixture = createOperations();
|
||||
const emit = vi.spyOn(fixture.context.events, "emitEvent");
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
emit.mockClear();
|
||||
|
||||
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
@@ -543,7 +548,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
emit.mockClear();
|
||||
finishInspection(["2-right"]);
|
||||
|
||||
@@ -577,7 +582,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
await vi.waitFor(() => expect(fixture.resolveByDeletingRevision).toHaveBeenCalledOnce());
|
||||
expect(fixture.storeContent).toHaveBeenCalledOnce();
|
||||
const logsBeforeDisposal = sink.mock.calls.length;
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishDeletion(result);
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
@@ -615,7 +620,7 @@ describe("interactive conflict resolution operations", () => {
|
||||
|
||||
const resolving = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledOnce());
|
||||
fixture.operations.dispose();
|
||||
fixture.abort();
|
||||
finishReplication({ status: "completed" });
|
||||
|
||||
await expect(resolving).resolves.toBe(false);
|
||||
@@ -695,8 +700,8 @@ describe("interactive conflict resolution operations", () => {
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
oldInstance.operations.dispose();
|
||||
oldInstance.operations.dispose();
|
||||
oldInstance.abort();
|
||||
oldInstance.abort();
|
||||
|
||||
await expect(Promise.all([oldActive, oldWaiting])).resolves.toEqual([false, false]);
|
||||
expect(oldInstance.dialogues).toHaveLength(1);
|
||||
@@ -1247,6 +1252,7 @@ describe("interactive conflict resolution feature composition", () => {
|
||||
|
||||
expect(completedOnUnload).toBe(true);
|
||||
expect(dialogues).toHaveLength(1);
|
||||
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||
await fixture.handlers.onUnload?.();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/
|
||||
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { withLock } from "octagonal-wheels/concurrency/lock";
|
||||
import { withCancellation } from "octagonal-wheels/concurrency/cancellation";
|
||||
import { withScope } from "octagonal-wheels/resource/scope";
|
||||
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";
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
|
||||
export interface InteractiveConflictResolutionOperationsDependencies {
|
||||
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
|
||||
readonly signal: AbortSignal;
|
||||
readonly databaseFileAccess: Pick<DatabaseFileAccess, "getConflictedRevs" | "storeContent">;
|
||||
readonly localDatabase: () => Pick<LiveSyncLocalDB, "getDBEntry" | "findAllDocs">;
|
||||
readonly confirm: Pick<Confirm, "askSelectString" | "askInPopup">;
|
||||
@@ -52,7 +55,6 @@ export interface InteractiveConflictResolutionOperationsDependencies {
|
||||
}
|
||||
|
||||
export interface InteractiveConflictResolutionOperations {
|
||||
readonly dispose: () => void;
|
||||
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
|
||||
readonly getActiveConflictMessages: () => Promise<string[]>;
|
||||
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
@@ -77,9 +79,18 @@ export function createInteractiveConflictResolutionOperations(
|
||||
const latestRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextRequestId = 0;
|
||||
let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined;
|
||||
let disposed = false;
|
||||
const clearPendingState = (): void => {
|
||||
latestRequestByFilename.clear();
|
||||
postponedConflictEpisodes.clear();
|
||||
};
|
||||
if (dependencies.signal.aborted) {
|
||||
clearPendingState();
|
||||
} else {
|
||||
dependencies.signal.addEventListener("abort", clearPendingState, { once: true });
|
||||
}
|
||||
|
||||
const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => {
|
||||
if (dependencies.signal.aborted) return;
|
||||
// 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
|
||||
@@ -88,23 +99,11 @@ export function createInteractiveConflictResolutionOperations(
|
||||
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> => {
|
||||
if (disposed) return undefined;
|
||||
if (dependencies.signal.aborted) return undefined;
|
||||
try {
|
||||
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
if (disposed) return undefined;
|
||||
if (dependencies.signal.aborted) return undefined;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
@@ -114,11 +113,11 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const getActiveConflictMessages = async (): Promise<string[]> => {
|
||||
if (disposed) return [];
|
||||
if (dependencies.signal.aborted) return [];
|
||||
const filename = dependencies.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (disposed) return [];
|
||||
if (dependencies.signal.aborted) return [];
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
@@ -137,9 +136,9 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
if (disposed) return;
|
||||
if (dependencies.signal.aborted) return;
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (disposed) return;
|
||||
if (dependencies.signal.aborted) return;
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
@@ -147,9 +146,11 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<boolean> => {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
if (dependencies.signal.aborted) return false;
|
||||
return await dependencies.conflict.ensureAllProcessed();
|
||||
};
|
||||
|
||||
@@ -157,22 +158,28 @@ export function createInteractiveConflictResolutionOperations(
|
||||
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 withLock(dialogueSerialisationKey, async () => {
|
||||
if (disposed || latestRequestByFilename.get(filename) !== requestId) {
|
||||
return false;
|
||||
if (dependencies.signal.aborted) return false;
|
||||
return await withScope(undefined, async (request) => {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
const requestId = ++nextRequestId;
|
||||
if (activeDialogue?.filename === filename) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
try {
|
||||
latestRequestByFilename.set(filename, requestId);
|
||||
request.defer(() => {
|
||||
if (latestRequestByFilename.get(filename) === requestId) {
|
||||
latestRequestByFilename.delete(filename);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 withLock(dialogueSerialisationKey, async () => {
|
||||
if (dependencies.signal.aborted || latestRequestByFilename.get(filename) !== requestId) {
|
||||
return false;
|
||||
}
|
||||
if (postponedConflictEpisodes.has(filename)) {
|
||||
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
@@ -183,14 +190,24 @@ export function createInteractiveConflictResolutionOperations(
|
||||
activeDialogue = { filename, dialogue };
|
||||
let selected: MergeDialogResult;
|
||||
try {
|
||||
dialogue.open();
|
||||
selected = await dialogue.waitForResult();
|
||||
selected = await withCancellation(dialogue, { signal: dependencies.signal }, async (waiting) => {
|
||||
const cancelDialogue = (): void => {
|
||||
if (activeDialogue?.dialogue === dialogue) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
}
|
||||
};
|
||||
waiting.signal.addEventListener("abort", cancelDialogue, { once: true });
|
||||
waiting.defer(() => waiting.signal.removeEventListener("abort", cancelDialogue));
|
||||
dialogue.open();
|
||||
if (waiting.isCancelled()) cancelDialogue();
|
||||
return await dialogue.waitForResult();
|
||||
});
|
||||
} finally {
|
||||
if (activeDialogue?.dialogue === dialogue) {
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
}
|
||||
if (disposed) return false;
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (selected === POSTPONED) {
|
||||
postponedConflictEpisodes.add(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
@@ -212,7 +229,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await refreshConflictState(filename);
|
||||
if (disposed) return false;
|
||||
if (dependencies.signal.aborted) return false;
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
@@ -224,7 +241,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await refreshConflictState(filename);
|
||||
if (disposed) return false;
|
||||
if (dependencies.signal.aborted) return false;
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
@@ -286,17 +303,15 @@ export function createInteractiveConflictResolutionOperations(
|
||||
// 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> => {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({
|
||||
id: doc._id,
|
||||
@@ -305,6 +320,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
mtime: doc.mtime,
|
||||
});
|
||||
}
|
||||
if (dependencies.signal.aborted) return false;
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
@@ -314,6 +330,7 @@ export function createInteractiveConflictResolutionOperations(
|
||||
return false;
|
||||
}
|
||||
const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await requestConflictResolution(targetItem.path);
|
||||
@@ -324,19 +341,22 @@ export function createInteractiveConflictResolutionOperations(
|
||||
|
||||
const allConflictCheck = async (): Promise<void> => {
|
||||
let notifyIfEmpty = true;
|
||||
while (await pickFileForResolve(notifyIfEmpty)) {
|
||||
while (!dependencies.signal.aborted && (await pickFileForResolve(notifyIfEmpty))) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scanStartupIssues = async (): Promise<boolean> => {
|
||||
if (dependencies.signal.aborted) return false;
|
||||
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 (dependencies.signal.aborted) return false;
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (dependencies.signal.aborted) return false;
|
||||
if (notes.length > 0) {
|
||||
dependencies.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
@@ -367,7 +387,6 @@ export function createInteractiveConflictResolutionOperations(
|
||||
};
|
||||
|
||||
return {
|
||||
dispose,
|
||||
invalidateWaitingResolution,
|
||||
getActiveConflictMessages,
|
||||
refreshConflictState,
|
||||
|
||||
Reference in new issue
Block a user