Own conflict cancellation at the feature boundary

This commit is contained in:
vorotamoroz committed 2026-11-05 08:36:39 +00:00
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);
});
}
@@ -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,