Merge branch 'main' into pr/zeedif/1059

This commit is contained in:
vorotamoroz
2026-07-31 06:00:59 +01:00
23 changed files with 2456 additions and 730 deletions
+1 -1
View File
@@ -37,7 +37,7 @@
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.51",
"octagonal-wheels": "^0.1.52",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
+1 -1
View File
@@ -15,7 +15,7 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.51"
"octagonal-wheels": "^0.1.52"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
+1 -1
View File
@@ -15,7 +15,7 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.51"
"octagonal-wheels": "^0.1.52"
},
"devDependencies": {
"eslint-plugin-svelte": "^3.19.0",
@@ -94,10 +94,8 @@ export const liveSyncProvisionalEnglishMessages = {
"🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}",
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B":
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
"📦 DB: recorded ${RECORDED} B · decoded unavailable":
"📦 DB: recorded ${RECORDED} B · decoded unavailable",
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B":
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
"📦 DB: recorded ${RECORDED} B · decoded unavailable": "📦 DB: recorded ${RECORDED} B · decoded unavailable",
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B": "📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})":
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
"✅ Matches Vault": "✅ Matches Vault",
@@ -130,8 +128,7 @@ export const liveSyncProvisionalEnglishMessages = {
"More actions for revision ${REVISION}": "More actions for revision ${REVISION}",
"More actions for ${FILE}": "More actions for ${FILE}",
"Show revision history": "Show revision history",
"Store Vault file as a new local database document":
"Store Vault file as a new local database document",
"Store Vault file as a new local database document": "Store Vault file as a new local database document",
"Copy database information": "Copy database information",
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
@@ -140,8 +137,7 @@ export const liveSyncProvisionalEnglishMessages = {
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.":
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
"Inspect conflicts and file/database differences":
"Inspect conflicts and file/database differences",
"Inspect conflicts and file/database differences": "Inspect conflicts and file/database differences",
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
"Begin inspection": "Begin inspection",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57 -1
View File
@@ -24,9 +24,16 @@ import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type PouchDB from "pouchdb-core";
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
const REPROCESS_BATCH_SIZE = 100;
type LocalApplicationActivityOwner = {
runBoundedLocalApplicationActivity<T>(
task: () => T | PromiseLike<T>,
options?: { label?: string }
): Promise<T>;
};
type ReplicateResultProcessorState = {
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
@@ -67,9 +74,11 @@ export class ReplicateResultProcessor {
public suspend() {
this._suspended = true;
this.updateProcessingActivity();
}
public resume() {
this._suspended = false;
this.updateProcessingActivity();
fireAndForget(() => this.runProcessQueue());
}
@@ -251,6 +260,40 @@ export class ReplicateResultProcessor {
*/
private _processingChanges: PouchDB.Core.ExistingDocument<EntryDoc>[] = [];
private _processingActivity?: Promise<void>;
private _processingActivityDone?: PromiseWithResolvers<void>;
private updateProcessingActivity() {
if (this.isSuspended) {
this._processingActivityDone?.resolve();
return;
}
const hasPendingDocuments = this._queuedChanges.length > 0 || this._processingChanges.length > 0;
if (!hasPendingDocuments) {
this._processingActivityDone?.resolve();
return;
}
if (this._processingActivity) return;
const activityDone = promiseWithResolvers<void>();
this._processingActivityDone = activityDone;
const activityOwner = this.services.replicator as typeof this.services.replicator &
Partial<LocalApplicationActivityOwner>;
this._processingActivity = (
activityOwner.runBoundedLocalApplicationActivity
? activityOwner.runBoundedLocalApplicationActivity(() => activityDone.promise, {
label: "replicated-document-application",
})
: activityDone.promise
)
.catch((error) => this.logError(error))
.finally(() => {
if (this._processingActivityDone === activityDone) this._processingActivityDone = undefined;
this._processingActivity = undefined;
this.updateProcessingActivity();
});
}
/**
* Enqueue the given document change for processing.
* @param doc Document change to enqueue
@@ -278,6 +321,7 @@ export class ReplicateResultProcessor {
}
// Enqueue the change
this._queuedChanges.push(doc);
this.updateProcessingActivity();
this.triggerTakeSnapshot();
this.triggerProcessQueue();
}
@@ -385,7 +429,19 @@ export class ReplicateResultProcessor {
} finally {
// Remove from processing queue
this._processingChanges = this._processingChanges.filter((e) => e !== change);
this.triggerTakeSnapshot();
try {
if (this._queuedChanges.length === 0 && this._processingChanges.length === 0) {
try {
await this._takeSnapshot();
} catch (error) {
this.logError(error);
}
} else {
this.triggerTakeSnapshot();
}
} finally {
this.updateProcessingActivity();
}
}
}
@@ -1,8 +1,67 @@
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { describe, expect, it, vi } from "vitest";
import type { EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
describe("ReplicateResultProcessor target-filter reprocessing", () => {
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
return {
_id: id,
_rev: "1-test",
path: `${id}.md`,
ctime: 1,
mtime: 2,
size: 1,
children: [],
datatype: "plain",
type: "plain",
eden: {},
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
}
type SetupOptions = {
processSynchroniseResult?: (entry: unknown) => Promise<void>;
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
};
function setup(options: SetupOptions = {}) {
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
const core = {
services: {
appLifecycle: { isReady: true, isSuspended: () => false },
path: { getPath: (entry: { path: string }) => entry.path },
replication: {
databaseQueueCount: reactiveSource(0),
storageApplyingCount: reactiveSource(0),
replicationResultCount: reactiveSource(0),
processVirtualDocument: vi.fn(async () => false),
processOptionalSynchroniseResult: vi.fn(async () => false),
processSynchroniseResult,
},
replicator: { runBoundedLocalApplicationActivity },
vault: {
isTargetFile: vi.fn(async () => true),
isFileSizeTooLarge: vi.fn(() => false),
isValidPath: vi.fn(() => true),
},
},
kvDB: { set: setSnapshot },
localDatabase: {
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
},
replicator: { closeReplication: vi.fn() },
};
const processor = new ReplicateResultProcessor({
core,
settings: { maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false },
} as never);
return { processor, processSynchroniseResult, runBoundedLocalApplicationActivity };
}
describe("ReplicateResultProcessor", () => {
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
const documents = [
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
@@ -22,4 +81,67 @@ describe("ReplicateResultProcessor target-filter reprocessing", () => {
expect(enqueueAll).toHaveBeenCalledOnce();
expect(enqueueAll).toHaveBeenCalledWith(documents);
});
it("keeps one local application activity until every replicated document has been applied", async () => {
const applying = promiseWithResolvers<void>();
let activityFinished = false;
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
processSynchroniseResult: async () => applying.promise,
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
activityFinished = true;
});
processor.enqueueAll([note("one"), note("two")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledTimes(2));
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(1);
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
label: "replicated-document-application",
});
expect(activityFinished).toBe(false);
applying.resolve();
await vi.waitFor(() => expect(activityFinished).toBe(true));
});
it("settles local application activity when the final recovery snapshot fails", async () => {
let activityFinished = false;
const { processor, runBoundedLocalApplicationActivity } = setup({
setSnapshot: async () => Promise.reject(new Error("snapshot failed")),
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
activityFinished = true;
});
processor.enqueueAll([note("one")]);
await vi.waitFor(() => expect(activityFinished).toBe(true));
});
it("releases and reacquires local application activity around processing suspension", async () => {
const applying = promiseWithResolvers<void>();
let completedActivities = 0;
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
processSynchroniseResult: async () => applying.promise,
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
completedActivities++;
});
processor.enqueueAll([note("one")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
processor.suspend();
await vi.waitFor(() => expect(completedActivities).toBe(1));
processor.resume();
await vi.waitFor(() => expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(2));
applying.resolve();
await vi.waitFor(() => expect(completedActivities).toBe(2));
});
});
@@ -113,9 +113,20 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
hasFocus = true;
isLastHidden = false;
private boundedRemoteActivityEndHandler?: (value: { readonly value: number }) => unknown;
private boundedActivityEndHandler?: (value: { readonly value: number }) => unknown;
private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible";
private get boundedActivityCounts(): ReactiveSource<number>[] {
const replicator = this.services.replicator as typeof this.services.replicator & {
boundedLocalApplicationActivityCount: ReactiveSource<number>;
};
return [replicator.boundedRemoteActivityCount, replicator.boundedLocalApplicationActivityCount];
}
private hasBoundedActivity() {
return this.boundedActivityCounts.some((count) => count.value > 0);
}
private keepReplicationActiveInBackground() {
return (
this.settings.keepReplicationActiveInBackground &&
@@ -125,9 +136,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
private async applyDeferredBoundedActivityLifecycle() {
const count = this.services.replicator.boundedRemoteActivityCount;
if (count.value !== 0) {
this.deferLifecycleUntilBoundedRemoteActivityEnds();
if (this.hasBoundedActivity()) {
this.deferLifecycleUntilBoundedActivityEnds();
return;
}
const deferredLifecycle = this.deferredBoundedLifecycle;
@@ -149,17 +159,17 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
}
private deferLifecycleUntilBoundedRemoteActivityEnds() {
if (this.boundedRemoteActivityEndHandler) return;
const count = this.services.replicator.boundedRemoteActivityCount;
const handler = (value: { readonly value: number }) => {
if (value.value !== 0) return;
count.offChanged(handler);
this.boundedRemoteActivityEndHandler = undefined;
private deferLifecycleUntilBoundedActivityEnds() {
if (this.boundedActivityEndHandler) return;
const counts = this.boundedActivityCounts;
const handler = () => {
if (this.hasBoundedActivity()) return;
for (const count of counts) count.offChanged(handler);
this.boundedActivityEndHandler = undefined;
fireAndForget(() => this.applyDeferredBoundedActivityLifecycle());
};
this.boundedRemoteActivityEndHandler = handler;
count.onChanged(handler);
this.boundedActivityEndHandler = handler;
for (const count of counts) count.onChanged(handler);
}
setHasFocus(hasFocus: boolean) {
@@ -188,12 +198,12 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
if (
this.settings.isConfigured &&
this.services.appLifecycle.isReady() &&
this.services.replicator.boundedRemoteActivityCount.value > 0
this.hasBoundedActivity()
) {
const isHidden = activeWindow.document.hidden;
this.isLastHidden = isHidden;
this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
}
return;
}
@@ -210,8 +220,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
return;
}
const boundedRemoteActivityInProgress = this.services.replicator.boundedRemoteActivityCount.value > 0;
if (!isHidden && boundedRemoteActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
const boundedActivityInProgress = this.hasBoundedActivity();
if (!isHidden && boundedActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
this.isLastHidden = false;
this.deferredBoundedLifecycle = undefined;
return;
@@ -228,18 +238,18 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
const keepActiveInBackground = this.keepReplicationActiveInBackground();
if (isHidden) {
if (boundedRemoteActivityInProgress && !keepActiveInBackground) {
if (boundedActivityInProgress && !keepActiveInBackground) {
this.deferredBoundedLifecycle = "suspend-if-hidden";
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
} else if (!keepActiveInBackground) {
await this.services.appLifecycle.onSuspending();
}
} else {
// suspend all temporary.
if (this.services.appLifecycle.isSuspended()) return;
if (boundedRemoteActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
if (boundedActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
this.deferredBoundedLifecycle = "restart-continuous-if-visible";
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
return;
}
// Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB
@@ -24,6 +24,7 @@ function setup(opts: SetupOptions) {
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0);
const core = {
_services: {
@@ -38,7 +39,7 @@ function setup(opts: SetupOptions) {
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
replicator: { boundedRemoteActivityCount },
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
},
settings: {
...DEFAULT_SETTINGS,
@@ -56,7 +57,13 @@ function setup(opts: SetupOptions) {
// The handler reads `activeWindow.document.hidden`.
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
return { module, appLifecycle, fileProcessing, boundedRemoteActivityCount };
return {
module,
appLifecycle,
fileProcessing,
boundedRemoteActivityCount,
boundedLocalApplicationActivityCount,
};
}
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
@@ -109,6 +116,21 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("defers suspension while local document application is active", async () => {
const { module, appLifecycle, boundedLocalApplicationActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
hidden: true,
});
boundedLocalApplicationActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
boundedLocalApplicationActivityCount.value = 0;
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("defers mobile suspension while bounded remote activity is running", async () => {
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
@@ -74,6 +74,11 @@ export class DocumentHistoryModal extends Modal {
currentDeleted = false;
initialRev?: string;
// Revision navigation state (◀/▶ beside the range slider)
revPrevBtn!: HTMLButtonElement;
revNextBtn!: HTMLButtonElement;
revNavIndicator!: HTMLSpanElement;
// Diff navigation state
currentDiffIndex = -1;
diffNavContainer!: HTMLDivElement;
@@ -84,6 +89,8 @@ export class DocumentHistoryModal extends Modal {
searchKeyword = "";
searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = [];
currentSearchIndex = -1;
searchPrevBtn!: HTMLButtonElement;
searchNextBtn!: HTMLButtonElement;
searchResultIndicator!: HTMLSpanElement;
searchProgressIndicator!: HTMLSpanElement;
searchTimeout: number | null = null;
@@ -125,12 +132,14 @@ export class DocumentHistoryModal extends Modal {
this.range.value = this.range.max;
this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`);
await this.loadRevs(initialRev);
this.updateRevisionNavUI();
} catch (ex) {
if (isErrorOfMissingDoc(ex)) {
this.range.max = "0";
this.range.value = "";
this.range.disabled = true;
this.contentView.setText(`We don't have any history for this note.`);
this.updateRevisionNavUI();
} else {
this.contentView.setText(`Error while loading file.`);
Logger(ex, LOG_LEVEL_VERBOSE);
@@ -148,6 +157,37 @@ export class DocumentHistoryModal extends Modal {
const index = this.revs_info.length - 1 - (Number(this.range.value) || 0);
const rev = this.revs_info[index];
await this.showExactRev(rev.rev);
this.updateRevisionNavUI();
}
navigateVersion(direction: "older" | "newer") {
const current = Number(this.range.value) || 0;
const max = Number(this.range.max) || 0;
if (direction === "older" && current > 0) {
this.range.value = `${current - 1}`;
} else if (direction === "newer" && current < max) {
this.range.value = `${current + 1}`;
} else {
return;
}
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
}
updateRevisionNavUI() {
if (!this.revNavIndicator) return;
const total = this.revs_info.length;
const max = Number(this.range.max) || 0;
const current = Number(this.range.value) || 0;
this.revNavIndicator.setText(total > 0 ? `Rev ${current + 1}/${total}` : "\u2014");
const disabled = !!this.range.disabled || total <= 1;
this.revPrevBtn.disabled = disabled || current <= 0;
this.revNextBtn.disabled = disabled || current >= max;
}
BlobURLs = new Map<string, string>();
@@ -391,6 +431,7 @@ export class DocumentHistoryModal extends Modal {
if (!keyword) {
this.searchResultIndicator.setText("");
this.searchProgressIndicator.setText("");
this.updateSearchUI();
return;
}
@@ -464,6 +505,10 @@ export class DocumentHistoryModal extends Modal {
const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0;
this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`);
}
const hasResults = this.searchResults.length > 0;
this.searchPrevBtn.disabled = !hasResults;
this.searchNextBtn.disabled = !hasResults;
}
navigateSearch(direction: "prev" | "next") {
@@ -515,12 +560,14 @@ export class DocumentHistoryModal extends Modal {
}, 500);
});
searchRow.createEl("button", { text: "\u25B2" }, (e) => {
this.searchPrevBtn = searchRow.createEl("button", { text: "\u25B2" }, (e) => {
e.title = "Previous match";
e.disabled = true;
e.addEventListener("click", () => this.navigateSearch("prev"));
});
searchRow.createEl("button", { text: "\u25BC" }, (e) => {
this.searchNextBtn = searchRow.createEl("button", { text: "\u25BC" }, (e) => {
e.title = "Next match";
e.disabled = true;
e.addEventListener("click", () => this.navigateSearch("next"));
});
@@ -530,18 +577,37 @@ export class DocumentHistoryModal extends Modal {
this.searchProgressIndicator = searchRow.createSpan({ text: "" });
this.searchProgressIndicator.addClass("history-search-progress-indicator");
const divView = contentEl.createDiv("");
divView.addClass("op-flex");
const revNavRow = contentEl.createDiv({ cls: "history-rev-nav-row" });
divView.createEl("input", { type: "range" }, (e) => {
this.revPrevBtn = revNavRow.createEl("button", { text: "\u25C0" }, (e) => {
e.addClass("history-rev-nav-btn");
e.title = "Older revision";
e.disabled = true;
e.addEventListener("click", () => this.navigateVersion("older"));
});
revNavRow.createEl("input", { type: "range" }, (e) => {
this.range = e;
e.addEventListener("change", (e) => {
e.addEventListener("change", () => {
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
e.addEventListener("input", (e) => {
e.addEventListener("input", () => {
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
});
this.revNextBtn = revNavRow.createEl("button", { text: "\u25B6" }, (e) => {
e.addClass("history-rev-nav-btn");
e.title = "Newer revision";
e.disabled = true;
e.addEventListener("click", () => this.navigateVersion("newer"));
});
this.revNavIndicator = revNavRow.createSpan({ text: "\u2014" }, (e) => {
e.addClass("history-rev-indicator");
});
const diffOptionsRow = contentEl.createDiv("");
diffOptionsRow.addClass("op-info");
diffOptionsRow.addClass("diff-options-row");
+22 -1
View File
@@ -10,11 +10,32 @@ import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/se
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
type ActivityOptions = {
label?: string;
};
export class ObsidianDatabaseEventService extends InjectableDatabaseEventService<ObsidianServiceContext> {}
// InjectableReplicatorService
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {}
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {
readonly boundedLocalApplicationActivityCount = reactiveSource(0);
async runBoundedLocalApplicationActivity<T>(
task: () => T | PromiseLike<T>,
options?: ActivityOptions
): Promise<T> {
this.boundedLocalApplicationActivityCount.value++;
try {
return this.dependencies.activityRunner
? await this.dependencies.activityRunner.run(task, options)
: await task();
} finally {
this.boundedLocalApplicationActivityCount.value--;
}
}
}
// InjectableFileProcessingService
export class ObsidianFileProcessingService extends InjectableFileProcessingService<ObsidianServiceContext> {}
// InjectableReplicationService
@@ -0,0 +1,35 @@
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { describe, expect, it, vi } from "vitest";
import { ObsidianReplicatorService } from "./ObsidianServices";
function handler() {
return { addHandler: vi.fn() };
}
describe("ObsidianReplicatorService", () => {
it("tracks local application activity without extending remote activity", async () => {
const activity = promiseWithResolvers<void>();
const service = new ObsidianReplicatorService({ events: {}, translate: String } as never, {
settingService: { onRealiseSetting: handler() },
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
databaseEventService: {
onResetDatabase: handler(),
onDatabaseInitialisation: handler(),
onDatabaseInitialised: handler(),
onDatabaseHasReady: handler(),
},
activityRunner: { run: vi.fn(async (task: () => Promise<void>) => await task()) },
} as never);
const running = service.runBoundedLocalApplicationActivity(() => activity.promise);
expect(service.boundedLocalApplicationActivityCount.value).toBe(1);
expect(service.boundedRemoteActivityCount.value).toBe(0);
activity.resolve();
await running;
expect(service.boundedLocalApplicationActivityCount.value).toBe(0);
expect(service.boundedRemoteActivityCount.value).toBe(0);
});
});
@@ -10,7 +10,8 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
constructor(private app: App) {}
async read(file: TFile): Promise<string> {
return await this.app.vault.read(file);
// Vault.read strips a leading UTF-8 BOM, leaving the content size inconsistent with TFile.stat.
return await this.app.vault.adapter.read(file.path);
}
async cachedRead(file: TFile): Promise<string> {
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import type { App, TFile } from "obsidian";
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
describe("ObsidianVaultAdapter.read", () => {
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
const path = "Transcripts/字幕.md";
const contentWithoutBom = "字幕の検証行です。\n";
const contentWithBom = `\ufeff${contentWithoutBom}`;
const read = vi.fn().mockResolvedValue(contentWithoutBom);
const adapterRead = vi.fn().mockResolvedValue(contentWithBom);
const app = {
vault: {
read,
adapter: {
read: adapterRead,
},
},
} as unknown as App;
const file = {
path,
stat: {
ctime: 1,
mtime: 2,
size: new Blob([contentWithBom]).size,
},
} as TFile;
const adapter = new ObsidianVaultAdapter(app);
const result = await adapter.read(file);
expect(new Blob([result]).size).toBe(file.stat.size);
expect(result.charCodeAt(0)).toBe(0xfeff);
expect(adapterRead).toHaveBeenCalledWith(path);
expect(read).not.toHaveBeenCalled();
});
});