mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-01 05:48:18 +00:00
Merge remote-tracking branch 'upstream/main' into feat/syncguard-bulk-sync-edit-guard
# Conflicts: # src/common/messages/LiveSyncProvisionalMessages.ts # styles.css # updates.md
This commit is contained in:
commit
acd3b0ade6
157 files changed
+15431
-1108
No files matched your search
@@ -4,7 +4,6 @@ import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
PREFIXMD_LOGFILE,
|
||||
type DatabaseConnectingStatus,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
@@ -31,16 +30,14 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import {
|
||||
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
import { REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, getTrackedRequestCount } from "./RemoteActivityStatus.ts";
|
||||
import {
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
import { createFastForwardCheckedChangesDisplay, projectReplicationStatusLabel } from "./ReplicationStatusLabel.ts";
|
||||
import { createStatusBarRenderer, type StatusBarPart, type StatusBarRenderer } from "./StatusBarRenderer.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
@@ -124,7 +121,14 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
logHistory?: HTMLDivElement;
|
||||
messageArea?: HTMLDivElement;
|
||||
|
||||
statusBarLabels!: ReactiveValue<{ message: string; status: string }>;
|
||||
statusBarRenderer?: StatusBarRenderer;
|
||||
statusLineRenderer?: StatusBarRenderer;
|
||||
statusBarLabels!: ReactiveValue<{
|
||||
message: string;
|
||||
status: string;
|
||||
parts: readonly StatusBarPart[];
|
||||
statusBarParts: readonly StatusBarPart[];
|
||||
}>;
|
||||
statusLog = reactiveSource("");
|
||||
activeFileStatus = reactiveSource("");
|
||||
notifies: { [key: string]: { count: number } } = {};
|
||||
@@ -164,79 +168,54 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
);
|
||||
|
||||
const requestingStatLabel = computed(() => {
|
||||
return formatRemoteActivityStatusLabel({
|
||||
return {
|
||||
remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
|
||||
trackedRequestCount: displayedTrackedRequestCount.value,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const replicationStatLabel = computed(() => {
|
||||
const e = this.services.replicator.replicationStatics.value;
|
||||
const sent = e.sent;
|
||||
const arrived = e.arrived;
|
||||
const maxPullSeq = e.maxPullSeq;
|
||||
const maxPushSeq = e.maxPushSeq;
|
||||
const lastSyncPullSeq = e.lastSyncPullSeq;
|
||||
const lastSyncPushSeq = e.lastSyncPushSeq;
|
||||
let pushLast = "";
|
||||
let pullLast = "";
|
||||
let w = "";
|
||||
const labels: Partial<Record<DatabaseConnectingStatus, string>> = {
|
||||
CONNECTED: "⚡",
|
||||
JOURNAL_SEND: "📦↑",
|
||||
JOURNAL_RECEIVE: "📦↓",
|
||||
};
|
||||
switch (e.syncStatus) {
|
||||
case "CLOSED":
|
||||
case "COMPLETED":
|
||||
case "NOT_CONNECTED":
|
||||
w = "⏹";
|
||||
break;
|
||||
case "STARTED":
|
||||
w = "🌀";
|
||||
break;
|
||||
case "PAUSED":
|
||||
w = "💤";
|
||||
break;
|
||||
case "CONNECTED":
|
||||
case "JOURNAL_SEND":
|
||||
case "JOURNAL_RECEIVE":
|
||||
w = labels[e.syncStatus] || "⚡";
|
||||
pushLast =
|
||||
lastSyncPushSeq == 0
|
||||
? ""
|
||||
: lastSyncPushSeq >= maxPushSeq
|
||||
? " (LIVE)"
|
||||
: ` (${maxPushSeq - lastSyncPushSeq})`;
|
||||
pullLast =
|
||||
lastSyncPullSeq == 0
|
||||
? ""
|
||||
: lastSyncPullSeq >= maxPullSeq
|
||||
? " (LIVE)"
|
||||
: ` (${maxPullSeq - lastSyncPullSeq})`;
|
||||
break;
|
||||
case "ERRORED":
|
||||
w = "⚠";
|
||||
break;
|
||||
default:
|
||||
w = "?";
|
||||
}
|
||||
return { w, sent, pushLast, arrived, pullLast };
|
||||
});
|
||||
const fastForwardCount = registerDisplay(
|
||||
createFastForwardCheckedChangesDisplay(this.services.replicator.replicationStatics)
|
||||
);
|
||||
const replicationStatLabel = computed(() =>
|
||||
projectReplicationStatusLabel(
|
||||
this.services.replicator.replicationStatics.value,
|
||||
requestingStatLabel(),
|
||||
fastForwardCount.value
|
||||
)
|
||||
);
|
||||
const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, `⏳`));
|
||||
const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`));
|
||||
const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`));
|
||||
const waitingLabel = computed(() => {
|
||||
return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
|
||||
});
|
||||
const counterPart = (key: string, mark: string, value: number | undefined): StatusBarPart[] =>
|
||||
value === undefined ? [] : [{ key, text: " " + mark, value }];
|
||||
const statusLineLabel = computed(() => {
|
||||
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
|
||||
const replication = replicationStatLabel();
|
||||
const queued = queueCountLabel();
|
||||
const waiting = waitingLabel();
|
||||
const networkActivity = requestingStatLabel();
|
||||
const p2p = this.p2pLogCollector.p2pReplicationLine.value;
|
||||
const firstLineParts = [
|
||||
...replication.parts,
|
||||
...counterPart("file-processing", "⏳", labelProc.count.value),
|
||||
...counterPart("file-pending", "🛫", labelPend.count.value),
|
||||
...counterPart("file-batched", "📬", labelInBatchDelay.count.value),
|
||||
...counterPart("queue-replication", "📥", labelReplication.count.value),
|
||||
...counterPart("queue-database", "📄", labelDBCount.count.value),
|
||||
...counterPart("queue-storage", "💾", labelStorageCount.count.value),
|
||||
...counterPart("queue-chunk-initial", "🛄", labelChunkCount.initialCount.value),
|
||||
...counterPart("queue-chunk-retrying", "🔁", labelChunkCount.retryingCount.value),
|
||||
...counterPart("queue-plugin-scan", "🔌", labelPluginScanCount.count.value),
|
||||
...counterPart("queue-hidden-files", "⚙️", labelHiddenFilesCount.count.value),
|
||||
...counterPart("queue-conflicts", "🔩", labelConflictProcessCount.count.value),
|
||||
];
|
||||
return {
|
||||
message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}${p2p == "" ? "" : "\n" + p2p}`,
|
||||
message: replication.message + waiting + queued + (p2p === "" ? "" : "\n" + p2p),
|
||||
parts: p2p === "" ? firstLineParts : [...firstLineParts, { key: "p2p", text: "\n" + p2p }],
|
||||
statusBarParts: firstLineParts,
|
||||
hasP2P: p2p !== "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -244,13 +223,16 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const scheduleMessage = this.services.appLifecycle.isReloadingScheduled()
|
||||
? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n`
|
||||
: "";
|
||||
const { message } = statusLineLabel();
|
||||
const { message, parts, statusBarParts, hasP2P } = statusLineLabel();
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
const status = scheduleMessage + this.statusLog.value;
|
||||
const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`;
|
||||
const skipPart = fileStatusIcon === "" ? [] : [{ key: "file-skip", text: fileStatusIcon }];
|
||||
return {
|
||||
message: `${message}${fileStatusIcon}`,
|
||||
status,
|
||||
parts: [...parts, ...skipPart],
|
||||
statusBarParts: hasP2P ? statusBarParts : [...statusBarParts, ...skipPart],
|
||||
};
|
||||
});
|
||||
this.statusBarLabels = statusBarLabels;
|
||||
@@ -391,7 +373,8 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
newLog = newLog.substring(moduleTagEnd + MARK_LOG_SEPARATOR.length + 1);
|
||||
}
|
||||
|
||||
this.statusBar?.setText(newMsg.split("\n")[0]);
|
||||
const { parts, statusBarParts } = this.statusBarLabels.value;
|
||||
this.statusBarRenderer?.render(statusBarParts);
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.setCssStyles({ display: this.settings?.showStatusOnEditor ? "" : "none" });
|
||||
}
|
||||
@@ -409,7 +392,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const recentLogs = recent.reverse().join("\n");
|
||||
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
|
||||
}
|
||||
if (isDirty("newMsg", newMsg)) this.statusLine!.innerText = newMsg;
|
||||
if (isDirty("newMsg", newMsg)) this.statusLineRenderer?.render(parts);
|
||||
if (isDirty("newLog", newLog)) this.logMessage!.innerText = newLog;
|
||||
} else {
|
||||
// const root = activeDocument.documentElement;
|
||||
@@ -505,6 +488,16 @@ ${stringifyYaml(info)}
|
||||
this.statusBar = this.services.API.addStatusBarItem();
|
||||
this.statusBar?.addClass("syncstatusbar");
|
||||
}
|
||||
if (this.statusLine) {
|
||||
const renderer = createStatusBarRenderer(this.statusLine);
|
||||
this.statusLineRenderer = renderer;
|
||||
this.plugin.register(() => renderer.dispose());
|
||||
}
|
||||
if (this.statusBar) {
|
||||
const renderer = createStatusBarRenderer(this.statusBar);
|
||||
this.statusBarRenderer = renderer;
|
||||
this.plugin.register(() => renderer.dispose());
|
||||
}
|
||||
this.adjustStatusDivPosition();
|
||||
this._log("Log module loaded", LOG_LEVEL_INFO);
|
||||
this._log("Verbose log", LOG_LEVEL_VERBOSE);
|
||||
|
||||
@@ -8,13 +8,13 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||
settingTab!: ObsidianLiveSyncSettingTab;
|
||||
|
||||
_everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
async _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
|
||||
this.settingTab.reloadAllSettings(true);
|
||||
await this.settingTab.loadSettings();
|
||||
this.plugin.addSettingTab(this.settingTab);
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
|
||||
|
||||
return Promise.resolve(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
openSetting() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const settingTabState = vi.hoisted(() => ({
|
||||
callOrder: [] as string[],
|
||||
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
|
||||
loadSettings: vi.fn<() => Promise<void>>(),
|
||||
}));
|
||||
|
||||
const eventHubState = vi.hoisted(() => ({
|
||||
@@ -11,9 +11,9 @@ const eventHubState = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
|
||||
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
|
||||
reloadAllSettings(skipUpdate?: boolean) {
|
||||
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
|
||||
settingTabState.reloadAllSettings(skipUpdate);
|
||||
async loadSettings() {
|
||||
await settingTabState.loadSettings();
|
||||
settingTabState.callOrder.push("settings-loaded");
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -65,7 +65,7 @@ function createModuleHarness() {
|
||||
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
settingTabState.callOrder.length = 0;
|
||||
settingTabState.reloadAllSettings.mockClear();
|
||||
settingTabState.loadSettings.mockReset().mockResolvedValue(undefined);
|
||||
eventHubState.onEvent.mockClear();
|
||||
});
|
||||
|
||||
@@ -79,13 +79,29 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
});
|
||||
|
||||
it("seeds the setting editor without requesting a render before registration", async () => {
|
||||
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
|
||||
const { initialisationHandler, settingsLoadedHandler, plugin } = createModuleHarness();
|
||||
let finishRead!: () => void;
|
||||
settingTabState.loadSettings.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishRead = resolve;
|
||||
})
|
||||
);
|
||||
const handler = settingsLoadedHandler() ?? initialisationHandler();
|
||||
|
||||
expect(handler).toBeTypeOf("function");
|
||||
await handler!();
|
||||
const loading = handler!();
|
||||
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||
finishRead();
|
||||
await loading;
|
||||
|
||||
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
|
||||
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
|
||||
expect(settingTabState.loadSettings).toHaveBeenCalledOnce();
|
||||
expect(settingTabState.callOrder).toEqual(["settings-loaded", "add-setting-tab"]);
|
||||
});
|
||||
it("does not register an editor when reading device values fails", async () => {
|
||||
const { settingsLoadedHandler, plugin } = createModuleHarness();
|
||||
const failure = new Error("device storage unavailable");
|
||||
settingTabState.loadSettings.mockRejectedValueOnce(failure);
|
||||
await expect(settingsLoadedHandler()!()).rejects.toBe(failure);
|
||||
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
import type { DisposableReactiveValue } from "./StatusBarDisplay.ts";
|
||||
import type { StatusBarPart } from "./StatusBarRenderer.ts";
|
||||
import {
|
||||
formatRemoteActivityStatusLabel,
|
||||
REMOTE_OPERATION_ACTIVITY_ICON,
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
type RemoteActivityStatus,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
|
||||
export const FAST_FORWARD_COUNT_LINGER_MS = 3_000;
|
||||
|
||||
function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>): number | undefined {
|
||||
const count = source.value.couchDB?.pull?.checkedChanges;
|
||||
return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Briefly displays changing CouchDB pull-feed comparison counts.
|
||||
*
|
||||
* Unrelated replication-stat changes do not extend the linger or redisplay an
|
||||
* expired count. A positive initial snapshot is displayed to late subscribers.
|
||||
*/
|
||||
export function createFastForwardCheckedChangesDisplay(
|
||||
source: ReactiveValue<ReplicationStatics>,
|
||||
lingerMs = FAST_FORWARD_COUNT_LINGER_MS
|
||||
): DisposableReactiveValue<number | undefined> {
|
||||
const displayed = reactiveSource<number | undefined>(undefined);
|
||||
const linger = Math.max(0, lingerMs);
|
||||
let lastObserved = readPositiveCheckedChanges(source);
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(clearTimer);
|
||||
clearTimer = undefined;
|
||||
};
|
||||
const scheduleClear = () => {
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
if (readPositiveCheckedChanges(source) !== lastObserved) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
displayed.value = undefined;
|
||||
}, linger);
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const nextCount = readPositiveCheckedChanges(source);
|
||||
if (nextCount === lastObserved) return;
|
||||
lastObserved = nextCount;
|
||||
cancelClear();
|
||||
if (nextCount === undefined) {
|
||||
displayed.value = undefined;
|
||||
return;
|
||||
}
|
||||
displayed.value = nextCount;
|
||||
scheduleClear();
|
||||
};
|
||||
|
||||
if (lastObserved !== undefined) {
|
||||
displayed.value = lastObserved;
|
||||
scheduleClear();
|
||||
}
|
||||
source.onChanged(update);
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return displayed.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
displayed.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
displayed.offChanged(handler);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
source.offChanged(update);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ReplicationStatusProjection = {
|
||||
icon: string;
|
||||
message: string;
|
||||
parts: StatusBarPart[];
|
||||
};
|
||||
|
||||
function remainingSequenceLabel(
|
||||
key: string,
|
||||
lastSyncSeq: number,
|
||||
maxSeq: number
|
||||
): { text: string; part?: StatusBarPart } {
|
||||
if (lastSyncSeq === 0) return { text: "" };
|
||||
if (lastSyncSeq >= maxSeq) return { text: " (LIVE)", part: { key, text: " (LIVE)" } };
|
||||
const remaining = maxSeq - lastSyncSeq;
|
||||
return {
|
||||
text: " (" + remaining + ")",
|
||||
part: { key, text: " (", value: remaining, suffix: ")" },
|
||||
};
|
||||
}
|
||||
|
||||
/** Projects the existing replication labels into reusable numeric status items. */
|
||||
export function projectReplicationStatusLabel(
|
||||
stats: ReplicationStatics,
|
||||
networkStatus: RemoteActivityStatus,
|
||||
fastForwardCount?: number
|
||||
): ReplicationStatusProjection {
|
||||
const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats;
|
||||
const networkActivity = formatRemoteActivityStatusLabel(networkStatus);
|
||||
let pushRemaining: { text: string; part?: StatusBarPart } = { text: "" };
|
||||
let pullRemaining: { text: string; part?: StatusBarPart } = { text: "" };
|
||||
let icon: string;
|
||||
switch (stats.syncStatus) {
|
||||
case "CLOSED":
|
||||
case "COMPLETED":
|
||||
case "NOT_CONNECTED":
|
||||
icon = "⏹";
|
||||
break;
|
||||
case "STARTED":
|
||||
icon = "🌀";
|
||||
break;
|
||||
case "PAUSED":
|
||||
icon = "💤";
|
||||
break;
|
||||
case "CONNECTED":
|
||||
case "JOURNAL_SEND":
|
||||
case "JOURNAL_RECEIVE":
|
||||
icon = stats.syncStatus === "JOURNAL_SEND" ? "📦↑" : stats.syncStatus === "JOURNAL_RECEIVE" ? "📦↓" : "⚡";
|
||||
pushRemaining = remainingSequenceLabel("push-remaining", lastSyncPushSeq, maxPushSeq);
|
||||
pullRemaining = remainingSequenceLabel("pull-remaining", lastSyncPullSeq, maxPullSeq);
|
||||
break;
|
||||
case "ERRORED":
|
||||
icon = "⚠";
|
||||
break;
|
||||
default:
|
||||
icon = "?";
|
||||
}
|
||||
|
||||
const couchDB = stats.couchDB;
|
||||
const isFastForwardChecking =
|
||||
stats.syncStatus !== "ERRORED" &&
|
||||
couchDB !== undefined &&
|
||||
couchDB.phase !== "idle" &&
|
||||
(couchDB.pull?.checking === true || couchDB.push?.checking === true);
|
||||
if (isFastForwardChecking) icon = "⏩";
|
||||
|
||||
const visibleFastForwardCount =
|
||||
couchDB !== undefined &&
|
||||
typeof fastForwardCount === "number" &&
|
||||
Number.isFinite(fastForwardCount) &&
|
||||
fastForwardCount > 0
|
||||
? fastForwardCount
|
||||
: undefined;
|
||||
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
|
||||
|
||||
const parts: StatusBarPart[] = [];
|
||||
if (networkStatus.trackedRequestCount > 0) {
|
||||
parts.push({
|
||||
key: "network",
|
||||
text:
|
||||
(networkStatus.remoteOperationCount > 0 ? `${REMOTE_OPERATION_ACTIVITY_ICON} ` : "") +
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
value: networkStatus.trackedRequestCount,
|
||||
suffix: " ",
|
||||
});
|
||||
} else if (networkActivity !== "") {
|
||||
parts.push({ key: "network", text: networkActivity });
|
||||
}
|
||||
parts.push({ key: "sync", text: "Sync: " + icon });
|
||||
if (visibleFastForwardCount !== undefined) {
|
||||
parts.push({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
value: visibleFastForwardCount,
|
||||
suffix: ")",
|
||||
title: "Compared CouchDB feed entries",
|
||||
});
|
||||
}
|
||||
parts.push({ key: "sent", text: " ↑ ", value: sent });
|
||||
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
|
||||
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
|
||||
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
|
||||
|
||||
return {
|
||||
icon,
|
||||
message:
|
||||
networkActivity +
|
||||
"Sync: " +
|
||||
icon +
|
||||
fastForwardLabel +
|
||||
" ↑ " +
|
||||
sent +
|
||||
pushRemaining.text +
|
||||
" ↓ " +
|
||||
arrived +
|
||||
pullRemaining.text,
|
||||
parts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
import {
|
||||
FAST_FORWARD_COUNT_LINGER_MS,
|
||||
createFastForwardCheckedChangesDisplay,
|
||||
projectReplicationStatusLabel,
|
||||
} from "./ReplicationStatusLabel.ts";
|
||||
|
||||
function couchDBProgress(
|
||||
phase: "idle" | "one-shot" | "catch-up" | "continuous",
|
||||
checkedChanges = 0,
|
||||
checking = false
|
||||
): NonNullable<ReplicationStatics["couchDB"]> {
|
||||
return {
|
||||
phase,
|
||||
pull: { checkedChanges, completedBatches: 0, checking },
|
||||
push: { checkedChanges: 0, completedBatches: 0, checking: false },
|
||||
};
|
||||
}
|
||||
|
||||
const noNetworkActivity = { remoteOperationCount: 0, trackedRequestCount: 0 };
|
||||
|
||||
function stats(overrides: Partial<ReplicationStatics> = {}): ReplicationStatics {
|
||||
return {
|
||||
sent: 0,
|
||||
arrived: 0,
|
||||
maxPullSeq: 0,
|
||||
maxPushSeq: 0,
|
||||
lastSyncPullSeq: 0,
|
||||
lastSyncPushSeq: 0,
|
||||
syncStatus: "CONNECTED",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectReplicationStatusLabel", () => {
|
||||
it("exposes the remote-request count as a numeric status item", () => {
|
||||
const projection = projectReplicationStatusLabel(stats(), { remoteOperationCount: 1, trackedRequestCount: 12 });
|
||||
expect(projection.parts.find((part) => part.key === "network")).toMatchObject({
|
||||
text: "📲 🌐",
|
||||
value: 12,
|
||||
suffix: " ",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the connected label, directions, and remaining-sequence text", () => {
|
||||
const projection = projectReplicationStatusLabel(
|
||||
stats({
|
||||
sent: 4,
|
||||
arrived: 2,
|
||||
maxPullSeq: 20,
|
||||
maxPushSeq: 30,
|
||||
lastSyncPullSeq: 17,
|
||||
lastSyncPushSeq: 22,
|
||||
}),
|
||||
{ remoteOperationCount: 0, trackedRequestCount: 1 }
|
||||
);
|
||||
|
||||
expect(projection.message).toBe("🌐1 Sync: ⚡ ↑ 4 (8) ↓ 2 (3)");
|
||||
expect(projection.parts.map((part) => part.key)).toEqual([
|
||||
"network",
|
||||
"sync",
|
||||
"sent",
|
||||
"push-remaining",
|
||||
"arrived",
|
||||
"pull-remaining",
|
||||
]);
|
||||
expect(projection.parts[2]).toMatchObject({ key: "sent", text: " ↑ ", value: 4 });
|
||||
expect(projection.parts[3]).toMatchObject({ key: "push-remaining", text: " (", value: 8, suffix: ")" });
|
||||
expect(projection.parts[4]).toMatchObject({ key: "arrived", text: " ↓ ", value: 2 });
|
||||
expect(projection.parts[5]).toMatchObject({ key: "pull-remaining", text: " (", value: 3, suffix: ")" });
|
||||
});
|
||||
|
||||
it("keeps the existing status icons when CouchDB is idle or not present", () => {
|
||||
const statuses = [
|
||||
["CLOSED", "⏹"],
|
||||
["COMPLETED", "⏹"],
|
||||
["NOT_CONNECTED", "⏹"],
|
||||
["STARTED", "🌀"],
|
||||
["PAUSED", "💤"],
|
||||
["CONNECTED", "⚡"],
|
||||
["JOURNAL_SEND", "📦↑"],
|
||||
["JOURNAL_RECEIVE", "📦↓"],
|
||||
["ERRORED", "⚠"],
|
||||
] as const;
|
||||
|
||||
for (const [syncStatus, icon] of statuses) {
|
||||
const idleWithCount = projectReplicationStatusLabel(
|
||||
stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }),
|
||||
noNetworkActivity,
|
||||
19
|
||||
);
|
||||
expect(idleWithCount.icon).toBe(icon);
|
||||
expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true);
|
||||
|
||||
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19);
|
||||
expect(withoutCouchDB.icon).toBe(icon);
|
||||
expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false);
|
||||
}
|
||||
|
||||
const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity);
|
||||
expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false);
|
||||
expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0");
|
||||
});
|
||||
|
||||
it("shows fast-forward checking with zero arrivals, transferred data, and the final idle count", () => {
|
||||
const checking = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "PAUSED",
|
||||
arrived: 0,
|
||||
couchDB: couchDBProgress("catch-up", 0, true),
|
||||
}),
|
||||
noNetworkActivity
|
||||
);
|
||||
expect(checking.icon).toBe("⏩");
|
||||
expect(checking.parts[2]).toMatchObject({ key: "arrived", value: 0 });
|
||||
|
||||
const transferred = projectReplicationStatusLabel(
|
||||
stats({
|
||||
arrived: 7,
|
||||
couchDB: couchDBProgress("continuous", 1200, false),
|
||||
}),
|
||||
noNetworkActivity,
|
||||
1200
|
||||
);
|
||||
expect(transferred.icon).toBe("⚡");
|
||||
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
|
||||
expect(transferred.parts[1]).toMatchObject({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
value: 1200,
|
||||
suffix: ")",
|
||||
});
|
||||
|
||||
const idle = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "CONNECTED",
|
||||
arrived: 7,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
}),
|
||||
noNetworkActivity,
|
||||
1200
|
||||
);
|
||||
expect(idle.icon).toBe("⚡");
|
||||
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||
});
|
||||
|
||||
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
|
||||
const projection = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "ERRORED",
|
||||
couchDB: {
|
||||
...couchDBProgress("one-shot", 2, true),
|
||||
push: { checkedChanges: 0, completedBatches: 0, checking: true },
|
||||
},
|
||||
}),
|
||||
noNetworkActivity
|
||||
);
|
||||
|
||||
expect(projection.icon).toBe("⚠");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFastForwardCheckedChangesDisplay", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the initial snapshot, expires unchanged counts, and only reappears for new progress", () => {
|
||||
const source = reactiveSource(
|
||||
stats({
|
||||
couchDB: couchDBProgress("catch-up", 1200, true),
|
||||
})
|
||||
);
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
expect(display.value).toBe(1200);
|
||||
vi.advanceTimersByTime(1000);
|
||||
source.value = stats({
|
||||
sent: 3,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
});
|
||||
expect(display.value).toBe(1200);
|
||||
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001);
|
||||
expect(display.value).toBe(1200);
|
||||
|
||||
source.value = stats({
|
||||
sent: 4,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
});
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats({
|
||||
syncStatus: "PAUSED",
|
||||
couchDB: couchDBProgress("continuous", 1200, true),
|
||||
});
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats({
|
||||
couchDB: couchDBProgress("continuous", 1201, true),
|
||||
});
|
||||
expect(display.value).toBe(1201);
|
||||
|
||||
source.value = stats({
|
||||
couchDB: couchDBProgress("one-shot", 0, true),
|
||||
});
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats();
|
||||
expect(display.value).toBeUndefined();
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("clears its timer and subscription when disposed", () => {
|
||||
const source = reactiveSource(stats({ couchDB: couchDBProgress("catch-up", 5, true) }));
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS);
|
||||
source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) });
|
||||
|
||||
expect(display.value).toBe(5);
|
||||
});
|
||||
|
||||
it("does not display progress for providers without CouchDB replication state", () => {
|
||||
const source = reactiveSource(stats());
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
expect(display.value).toBeUndefined();
|
||||
source.value = stats({ couchDB: { phase: "continuous" } });
|
||||
expect(display.value).toBeUndefined();
|
||||
display.dispose();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export function createAdvancedSettingSpecGroups({
|
||||
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
|
||||
},
|
||||
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
|
||||
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
|
||||
{
|
||||
key: "pathNormalizationVersion",
|
||||
holdValue: true,
|
||||
|
||||
+8
-6
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
|
||||
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
@@ -459,7 +461,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
expect(runtime.pageCleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => {
|
||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -468,7 +470,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
changeLog.page().display();
|
||||
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -482,7 +484,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds translated catalogue names when the display language changes externally", () => {
|
||||
it("rebuilds translated catalogue names when the display language changes externally", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -491,12 +493,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
changeLog.page().display();
|
||||
tab.core.settings.displayLanguage = "ja";
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => {
|
||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -507,7 +509,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
tab.editingSettings.usePowerUserMode = true;
|
||||
tab.core.settings.usePowerUserMode = true;
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
|
||||
expect(configureAnchor).toBeTypeOf("function");
|
||||
let acceptExternalSetting: (() => void) | undefined;
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type PageFunctions,
|
||||
type UpdateFunction,
|
||||
} from "./SettingPane.ts";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
|
||||
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
selectedScreen = "";
|
||||
|
||||
private localConfigurationPassphrase = "";
|
||||
private localSettingsReadVersion = 0;
|
||||
|
||||
_editingSettings?: AllSettings;
|
||||
// Buffered Settings for editing
|
||||
get editingSettings(): AllSettings {
|
||||
@@ -162,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
|
||||
if (key == "deviceAndVaultName") {
|
||||
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
|
||||
this.services.setting.saveDeviceAndVaultName();
|
||||
return await Promise.resolve();
|
||||
await this.services.setting.saveDeviceAndVaultName();
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -225,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
appliedConfigurationEncryption.configPassphrase
|
||||
);
|
||||
|
||||
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
|
||||
this.localSettingsReadVersion++;
|
||||
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
|
||||
}
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
|
||||
}
|
||||
@@ -285,9 +290,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
}
|
||||
|
||||
private async readLocalSettings(): Promise<boolean> {
|
||||
const version = ++this.localSettingsReadVersion;
|
||||
const passphrase = await this.services.setting.deviceValues.getValue("ls-setting-passphrase", "");
|
||||
if (version !== this.localSettingsReadVersion) return false;
|
||||
this.localConfigurationPassphrase = passphrase;
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
if (await this.readLocalSettings()) this.reloadAllSettings(true);
|
||||
}
|
||||
|
||||
reloadAllLocalSettings() {
|
||||
const ret = { ...OnDialogSettingsDefault };
|
||||
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
|
||||
ret.configPassphrase = this.localConfigurationPassphrase;
|
||||
ret.preset = "";
|
||||
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
||||
return ret;
|
||||
@@ -402,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
this.plugin = plugin;
|
||||
Setting.env = this;
|
||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
||||
this.requestReload();
|
||||
void this.requestReload().catch((error: unknown) => Logger(error, LOG_LEVEL_NOTICE));
|
||||
});
|
||||
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
|
||||
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
|
||||
@@ -545,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
);
|
||||
}
|
||||
|
||||
requestReload() {
|
||||
async requestReload(): Promise<void> {
|
||||
if (!(await this.readLocalSettings())) return;
|
||||
const nativeTabIsShown =
|
||||
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
|
||||
if (this.isShown || nativeTabIsShown) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
|
||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||
applySettingsWithInitialisationChoice: vi.fn(),
|
||||
@@ -76,8 +76,6 @@ beforeEach(() => {
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||
loggerMocks.Logger.mockClear();
|
||||
vi.mocked($msg).mockClear();
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
suspendAllSync,
|
||||
@@ -665,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
},
|
||||
@@ -692,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
return { tab, saveSettingData };
|
||||
}
|
||||
|
||||
it("awaits the stored passphrase before initialising the editing buffer", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
tab.resetEditingSettings();
|
||||
let resolveRead!: (value: string) => void;
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const loading = tab.loadSettings();
|
||||
expect(tab.initialSettings).toBeUndefined();
|
||||
resolveRead("stored passphrase");
|
||||
await loading;
|
||||
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||
});
|
||||
|
||||
it("preserves the loaded settings when device storage cannot be read", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "stored passphrase");
|
||||
await tab.loadSettings();
|
||||
const failure = new Error("device storage unavailable");
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockRejectedValueOnce(failure);
|
||||
await expect(tab.requestReload()).rejects.toBe(failure);
|
||||
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||
});
|
||||
|
||||
it("does not let a delayed read overwrite a successfully saved passphrase", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
await tab.loadSettings();
|
||||
let resolveRead!: (value: string) => void;
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const loading = tab.loadSettings();
|
||||
Object.assign(tab.services.setting, { changeConfigurationEncryption: vi.fn(async () => undefined) });
|
||||
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.editingSettings.configPassphrase = "new passphrase";
|
||||
await tab.saveSettings(["configPassphraseStore", "configPassphrase"]);
|
||||
resolveRead("old passphrase");
|
||||
await loading;
|
||||
tab.reloadAllSettings();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new passphrase");
|
||||
});
|
||||
|
||||
it("loads the imperative fallback without a SettingPage runtime export", () => {
|
||||
const { tab } = createSettingsTab();
|
||||
|
||||
@@ -779,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
it("saves a custom configuration passphrase edit through the setting service", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
const changeConfigurationEncryption = vi.fn(async () => {
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase");
|
||||
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "new custom passphrase");
|
||||
});
|
||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||
const store = "LOCALSTORAGE";
|
||||
@@ -787,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.initialSettings!.configPassphraseStore = store;
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||
|
||||
await tab.saveSettings(["configPassphrase"]);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(setValue).toHaveBeenCalledOnce();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
|
||||
});
|
||||
|
||||
@@ -811,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.initialSettings!.configPassphrase = "previous passphrase";
|
||||
tab.editingSettings.configPassphrase = "passphrase A";
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
|
||||
|
||||
const apply = tab.saveSettings(["configPassphrase"]);
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
|
||||
@@ -840,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.editingSettings.configPassphraseStore = editedStore;
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||
|
||||
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(setValue).not.toHaveBeenCalled();
|
||||
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
|
||||
};
|
||||
|
||||
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
|
||||
|
||||
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
|
||||
"Memory cache",
|
||||
@@ -56,6 +56,7 @@ describe("paneAdvanced", () => {
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
@@ -67,6 +68,7 @@ describe("paneAdvanced", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
|
||||
import { Menu, type ButtonComponent } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -101,6 +102,22 @@ export function paneRemoteConfig(
|
||||
paneEl: HTMLElement,
|
||||
{ addPanel }: PageFunctions
|
||||
): void {
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Joined devices"))
|
||||
.setDesc($msg("Review device records for the active CouchDB or Object Storage connection."))
|
||||
.addButton((button) =>
|
||||
button.setButtonText($msg("Review devices")).onClick(async () => {
|
||||
const result = await this.services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
if (result.status === "unavailable" || result.value === "unavailable") {
|
||||
this.services.API.addLog(
|
||||
$msg(
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
{
|
||||
/* E2EE */
|
||||
const E2EEInitialProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
buttonClasses: [] as string[],
|
||||
clickHandlers: [] as Array<() => Promise<void> | void>,
|
||||
clickHandlers: [] as Array<{ setting: string; run: () => Promise<void> | void }>,
|
||||
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
|
||||
settingClasses: [] as string[],
|
||||
}));
|
||||
@@ -32,7 +32,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
},
|
||||
};
|
||||
|
||||
setName() {
|
||||
settingName = "";
|
||||
|
||||
setName(name: string) {
|
||||
this.settingName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -41,6 +44,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
}
|
||||
|
||||
addButton(callback: (button: unknown) => void) {
|
||||
const setting = this.settingName;
|
||||
const button = {
|
||||
buttonEl: {
|
||||
classList: {
|
||||
@@ -53,7 +57,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
},
|
||||
onClick(callback: () => Promise<void> | void) {
|
||||
runtime.clickHandlers.push(callback);
|
||||
runtime.clickHandlers.push({ setting, run: callback });
|
||||
return this;
|
||||
},
|
||||
setButtonText() {
|
||||
@@ -107,6 +111,7 @@ vi.mock("./remoteConfigBuffer.ts", () => ({
|
||||
syncActivatedRemoteSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
|
||||
import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
|
||||
|
||||
function createPanelElement(): HTMLElement {
|
||||
@@ -124,6 +129,18 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("paneRemoteConfig", () => {
|
||||
it("opens joined devices through the shared UI command", async () => {
|
||||
const invoke = vi.fn(async () => ({ status: "handled", value: "closed" }));
|
||||
const host = {
|
||||
editingSettings: { remoteConfigurations: {} },
|
||||
services: { UI: { commands: { invoke } } },
|
||||
};
|
||||
const addPanel = vi.fn(() => ({ then: vi.fn() }));
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Joined devices")!.run();
|
||||
expect(invoke).toHaveBeenCalledWith(OPEN_JOINED_DEVICES, undefined);
|
||||
});
|
||||
|
||||
it("destroys the E2EE info panel when the settings page lifetime unloads", async () => {
|
||||
const callbacks: Array<() => unknown> = [];
|
||||
const lifetimeComponent = {
|
||||
@@ -187,7 +204,7 @@ describe("paneRemoteConfig", () => {
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
|
||||
|
||||
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
|
||||
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
|
||||
@@ -233,7 +250,7 @@ describe("paneRemoteConfig", () => {
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
|
||||
|
||||
expect(host.editingSettings.idDerivationVersion).toBe(1);
|
||||
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
|
||||
|
||||
@@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
.setName($msg("Synchronising files"))
|
||||
.setDesc(
|
||||
$msg(
|
||||
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files."
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
|
||||
)
|
||||
);
|
||||
const syncFilesControl = mount(MultipleRegExpControl, {
|
||||
@@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
|
||||
const nonSyncFilesSetting = new Setting(paneEl)
|
||||
.setName($msg("Non-Synchronising files"))
|
||||
.setDesc(
|
||||
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
|
||||
);
|
||||
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
||||
|
||||
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
||||
target: nonSyncFilesSetting.controlEl,
|
||||
|
||||
@@ -171,20 +171,18 @@ export function paneSyncSettings(
|
||||
}
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
paneEl,
|
||||
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"),
|
||||
undefined,
|
||||
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
|
||||
).then((paneEl) => {
|
||||
new Setting(paneEl).autoWireToggle("batchSave");
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
|
||||
const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
|
||||
const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
|
||||
new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
|
||||
new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
|
||||
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
onUpdate: visibleOnly(batchEnabled),
|
||||
});
|
||||
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
onUpdate: visibleOnly(batchEnabled),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
|
||||
|
||||
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
autoWireDropDown(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
autoWireToggle(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { paneSyncSettings } from "./PaneSyncSettings";
|
||||
|
||||
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
|
||||
const callbacks = new Map<string, (value: string) => Promise<void>>();
|
||||
const panels = new Map<string, OnUpdateFunc | undefined>();
|
||||
const tab = {
|
||||
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
|
||||
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
|
||||
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
|
||||
isConfiguredAs(key: string, value: unknown) {
|
||||
if (key === "syncMode") {
|
||||
return (
|
||||
(this.editingSettings.liveSync
|
||||
? "LIVESYNC"
|
||||
: this.editingSettings.periodicReplication
|
||||
? "PERIODIC"
|
||||
: "ONEVENTS") === value
|
||||
);
|
||||
}
|
||||
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
|
||||
},
|
||||
saveSettings: vi.fn(async () => {}),
|
||||
requestCatalogueRefresh: vi.fn(),
|
||||
};
|
||||
const functions = {
|
||||
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
|
||||
then(callback: (element: HTMLElement) => void) {
|
||||
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
|
||||
panels.set(title, onUpdate);
|
||||
callback({} as HTMLElement);
|
||||
},
|
||||
}),
|
||||
} as unknown as PageFunctions;
|
||||
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
|
||||
return { tab, callbacks, panels };
|
||||
}
|
||||
|
||||
function visible(key: string): boolean {
|
||||
const control = controls.get(key);
|
||||
if (!control) throw new Error(`Missing setting control: ${key}`);
|
||||
return control.onUpdate?.().visibility !== false;
|
||||
}
|
||||
|
||||
beforeEach(() => controls.clear());
|
||||
|
||||
describe("mode-specific database batching settings", () => {
|
||||
it.each([
|
||||
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
|
||||
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
|
||||
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
|
||||
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
|
||||
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
|
||||
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
|
||||
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
|
||||
expect(visible("batchSave")).toBe(!settings.liveSync);
|
||||
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
|
||||
expect(visible("batchSaveMinimumDelay")).toBe(delays);
|
||||
expect(visible("batchSaveMaximumDelay")).toBe(delays);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, false],
|
||||
[false, true],
|
||||
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
|
||||
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
|
||||
const switchMode = callbacks.get("syncMode")!;
|
||||
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
|
||||
await switchMode(mode);
|
||||
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
|
||||
}
|
||||
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const rangeMessages = {
|
||||
};
|
||||
|
||||
describe("Advanced setting specifications", () => {
|
||||
it("lists only the ten controls currently exposed by the Advanced page", () => {
|
||||
it("lists only the eleven controls currently exposed by the Advanced page", () => {
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
|
||||
|
||||
expect(groups.map(({ heading }) => heading)).toEqual([
|
||||
@@ -30,6 +30,7 @@ describe("Advanced setting specifications", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
@@ -40,6 +41,17 @@ describe("Advanced setting specifications", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes strict content checking as an ungated Advanced toggle", () => {
|
||||
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => false })
|
||||
.find(({ heading }) => heading === "Local Database Tweak")
|
||||
?.items.find(({ key }) => key === "strictContentCheckOnStartup");
|
||||
if (!spec) throw new Error("Strict content checking is not exposed on the Advanced page");
|
||||
|
||||
expect(spec.control).toEqual({ type: "toggle" });
|
||||
expect(spec.visible).toBeUndefined();
|
||||
expect(spec.disabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
|
||||
let couchDB = false;
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("settings page catalogue", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -37,11 +38,6 @@ import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { SetupManager, UserMode } from "./SetupManager";
|
||||
|
||||
class TestSettingService extends SettingService<ServiceContext> {
|
||||
protected setItem(_key: string, _value: string): void {}
|
||||
protected getItem(_key: string): string {
|
||||
return "";
|
||||
}
|
||||
protected deleteItem(_key: string): void {}
|
||||
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -65,6 +61,7 @@ function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
|
||||
|
||||
function createSetupManager() {
|
||||
const setting = new TestSettingService(new ServiceContext(), {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
APIService: {
|
||||
getSystemVaultName: vi.fn(() => "vault"),
|
||||
getAppID: vi.fn(() => "app"),
|
||||
@@ -172,10 +169,12 @@ describe("SetupManager", () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
|
||||
const newVaultSettings = createNewVaultSettings();
|
||||
|
||||
await manager.onOnboard(UserMode.NewUser);
|
||||
|
||||
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
|
||||
expect(newVaultSettings.customChunkSize).toBe(0);
|
||||
expect(configureManually).toHaveBeenCalledWith(newVaultSettings, UserMode.NewUser);
|
||||
});
|
||||
|
||||
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
|
||||
@@ -487,8 +486,15 @@ describe("SetupManager", () => {
|
||||
expect(applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
|
||||
it.each([
|
||||
{ importedSize: 0, expectedSize: 0 },
|
||||
{ importedSize: undefined, expectedSize: 60 },
|
||||
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
@@ -507,6 +513,8 @@ describe("SetupManager", () => {
|
||||
},
|
||||
activeConfigurationId: "archive",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
|
||||
else imported.customChunkSize = importedSize;
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(imported)
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
@@ -514,6 +522,7 @@ describe("SetupManager", () => {
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(expectedSize);
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
@@ -523,6 +532,7 @@ describe("SetupManager", () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
@@ -554,6 +564,7 @@ describe("SetupManager", () => {
|
||||
await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(60);
|
||||
expect(current.remoteConfigurations.existing).toBeDefined();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
expect(current.activeConfigurationId).not.toBe("existing");
|
||||
@@ -662,6 +673,7 @@ describe("SetupManager", () => {
|
||||
await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(0);
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(1);
|
||||
expect(current.activeConfigurationId).not.toBe("");
|
||||
expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId);
|
||||
|
||||
@@ -52,26 +52,48 @@
|
||||
const context = getDialogContext();
|
||||
let error = $state("");
|
||||
let connectionPathResetNotice = $state(false);
|
||||
let peerNameReady = $state(false);
|
||||
let mounted = false;
|
||||
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const initialData = getInitialData?.();
|
||||
connectionProbe = initialData?.connectionProbe;
|
||||
const initialSettings = initialData?.settings;
|
||||
if (initialSettings) {
|
||||
syncSetting = pickP2PSyncSettings(initialSettings);
|
||||
}
|
||||
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
|
||||
const savedPeerName = initialSettings?.P2P_DevicePeerName;
|
||||
const initialPeerName = typeof savedPeerName === "string" ? savedPeerName.trim() : "";
|
||||
if (initialPeerName !== "") {
|
||||
return;
|
||||
}
|
||||
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME);
|
||||
if (cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
|
||||
peerNameReady = true;
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}
|
||||
void context.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME).then(
|
||||
(cachedPeerName) => {
|
||||
if (mounted) {
|
||||
if (typeof cachedPeerName === "string" && cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName;
|
||||
}
|
||||
peerNameReady = true;
|
||||
}
|
||||
},
|
||||
(loadError: unknown) => {
|
||||
if (mounted) {
|
||||
error = loadError instanceof Error ? loadError.message : String(loadError);
|
||||
peerNameReady = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
});
|
||||
function generateSetting() {
|
||||
const connSetting: P2PSyncSetting = {
|
||||
@@ -193,6 +215,7 @@
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
if (!peerNameReady) return;
|
||||
error = "";
|
||||
try {
|
||||
error = (await checkConnection()) || "";
|
||||
@@ -207,6 +230,7 @@
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
if (!peerNameReady) return;
|
||||
error = validateManagedTurnSettings(syncSetting) ?? "";
|
||||
if (error) return;
|
||||
const setting = pickP2PSyncSettings(generateSetting());
|
||||
@@ -217,6 +241,7 @@
|
||||
}
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
peerNameReady &&
|
||||
syncSetting.P2P_relays.trim() !== "" &&
|
||||
syncSetting.P2P_roomID.trim() !== "" &&
|
||||
syncSetting.P2P_passphrase.trim() !== "" &&
|
||||
@@ -291,6 +316,7 @@
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
disabled={!peerNameReady}
|
||||
bind:value={syncSetting.P2P_DevicePeerName}
|
||||
/>
|
||||
</InputRow>
|
||||
@@ -385,7 +411,11 @@
|
||||
disabled={!canProceed}
|
||||
commit={() => checkAndCommit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} />
|
||||
<Decision
|
||||
title={translateMessage("Continue anyway")}
|
||||
disabled={!peerNameReady}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -10,6 +10,15 @@ export type DisposableReactiveValue<T> = ReactiveValue<T> & {
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export type PaddedCounterLabel = DisposableReactiveValue<string> & {
|
||||
readonly count: ReactiveValue<number | undefined>;
|
||||
};
|
||||
|
||||
export type ChunkFetchCounterLabel = DisposableReactiveValue<string> & {
|
||||
readonly initialCount: ReactiveValue<number | undefined>;
|
||||
readonly retryingCount: ReactiveValue<number | undefined>;
|
||||
};
|
||||
|
||||
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
|
||||
return {
|
||||
get value() {
|
||||
@@ -92,9 +101,10 @@ export function createPaddedCounterLabel(
|
||||
source: ReactiveValue<number>,
|
||||
mark: string,
|
||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
|
||||
): DisposableReactiveValue<string> {
|
||||
): PaddedCounterLabel {
|
||||
const linger = Math.max(0, inactiveLingerMs);
|
||||
const formatted = reactiveSource("");
|
||||
const displayedCount = reactiveSource<number | undefined>(undefined);
|
||||
let maximumLength = 1;
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
@@ -114,22 +124,39 @@ export function createPaddedCounterLabel(
|
||||
cancelClear();
|
||||
const count = source.value;
|
||||
formatted.value = format(count);
|
||||
displayedCount.value = count;
|
||||
if (count !== 0) return;
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
formatted.value = "";
|
||||
displayedCount.value = undefined;
|
||||
maximumLength = 1;
|
||||
}, linger);
|
||||
};
|
||||
|
||||
source.onChanged(update);
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
const display = asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
source.offChanged(update);
|
||||
});
|
||||
return {
|
||||
get value() {
|
||||
return display.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
display.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
display.offChanged(handler);
|
||||
},
|
||||
count: displayedCount,
|
||||
dispose() {
|
||||
display.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +165,7 @@ export function createPaddedCounterLabel(
|
||||
*/
|
||||
export function createChunkFetchCounterLabel(
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>
|
||||
): DisposableReactiveValue<string> {
|
||||
): ChunkFetchCounterLabel {
|
||||
const initialCount = reactiveSource(0);
|
||||
const retryingCount = reactiveSource(0);
|
||||
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
|
||||
@@ -168,7 +195,7 @@ export function createChunkFetchCounterLabel(
|
||||
source.onChanged(updateCounts);
|
||||
updateCounts();
|
||||
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
const display = asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
source.offChanged(updateCounts);
|
||||
@@ -177,4 +204,20 @@ export function createChunkFetchCounterLabel(
|
||||
initialLabel.dispose();
|
||||
retryingLabel.dispose();
|
||||
});
|
||||
return {
|
||||
get value() {
|
||||
return display.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
display.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
display.offChanged(handler);
|
||||
},
|
||||
initialCount: initialLabel.count,
|
||||
retryingCount: retryingLabel.count,
|
||||
dispose() {
|
||||
display.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -111,6 +111,24 @@ describe("createPaddedCounterLabel", () => {
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("exposes the numeric value while retaining zero through the existing linger", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📥");
|
||||
|
||||
expect(display.count.value).toBeUndefined();
|
||||
source.value = 9;
|
||||
expect(display.count.value).toBe(9);
|
||||
source.value = 0;
|
||||
expect(display.count.value).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
|
||||
expect(display.count.value).toBe(0);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.count.value).toBeUndefined();
|
||||
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels the pending clear when counter activity resumes", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📄");
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
const INACTIVE_CLASS = "livesync-stat-inactive";
|
||||
|
||||
export type StatusBarPart = {
|
||||
key: string;
|
||||
text: string;
|
||||
value?: number;
|
||||
suffix?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type StatusBarRenderer = {
|
||||
render(parts: readonly StatusBarPart[]): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
type RenderedItem = {
|
||||
element: HTMLSpanElement;
|
||||
prefix: Text;
|
||||
value: HTMLSpanElement;
|
||||
valueText: Text;
|
||||
suffix: Text;
|
||||
widestValueLength: number;
|
||||
};
|
||||
|
||||
export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer {
|
||||
const items = new Map<string, RenderedItem>();
|
||||
let disposed = false;
|
||||
|
||||
const setText = (node: Text, text: string) => {
|
||||
if (node.data !== text) node.data = text;
|
||||
};
|
||||
|
||||
const setHidden = (element: HTMLElement, hidden: boolean) => {
|
||||
if (element.hidden !== hidden) element.hidden = hidden;
|
||||
};
|
||||
|
||||
const setInactive = (element: HTMLElement, inactive: boolean) => {
|
||||
if (element.classList.contains(INACTIVE_CLASS) === inactive) return;
|
||||
element.classList.toggle(INACTIVE_CLASS, inactive);
|
||||
if (inactive) element.setAttribute("aria-hidden", "true");
|
||||
else element.removeAttribute("aria-hidden");
|
||||
};
|
||||
|
||||
const setWidth = (element: HTMLElement, width: string) => {
|
||||
if (element.style.getPropertyValue("--livesync-stat-width") !== width) {
|
||||
element.setCssProps({ "--livesync-stat-width": width });
|
||||
}
|
||||
};
|
||||
|
||||
const createItem = (key: string): RenderedItem => {
|
||||
const document = parent.ownerDocument;
|
||||
const element = parent.createSpan();
|
||||
element.className = "livesync-stat-item livesync-stat-" + key;
|
||||
|
||||
const prefix = document.createTextNode("");
|
||||
const value = element.createSpan();
|
||||
value.className = "livesync-stat-value";
|
||||
const valueText = document.createTextNode("");
|
||||
value.appendChild(valueText);
|
||||
value.hidden = true;
|
||||
const suffix = document.createTextNode("");
|
||||
|
||||
element.appendChild(prefix);
|
||||
element.appendChild(value);
|
||||
element.appendChild(suffix);
|
||||
|
||||
const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 };
|
||||
items.set(key, item);
|
||||
return item;
|
||||
};
|
||||
|
||||
const updateItem = (item: RenderedItem, part: StatusBarPart) => {
|
||||
setText(item.prefix, part.text);
|
||||
const title = part.title ?? "";
|
||||
if (item.element.title !== title) item.element.title = title;
|
||||
|
||||
if (part.value === undefined) {
|
||||
setText(item.valueText, "");
|
||||
setText(item.suffix, "");
|
||||
setHidden(item.value, true);
|
||||
setWidth(item.value, "");
|
||||
item.widestValueLength = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedValue = String(part.value);
|
||||
setText(item.valueText, formattedValue);
|
||||
setText(item.suffix, part.suffix ?? "");
|
||||
setHidden(item.value, false);
|
||||
|
||||
if (formattedValue.length > item.widestValueLength) {
|
||||
item.widestValueLength = formattedValue.length;
|
||||
const width = item.widestValueLength + "ch";
|
||||
setWidth(item.value, width);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => {
|
||||
const desiredOrder = visibleItems.map((item) => item.element);
|
||||
const visibleElements = new Set(desiredOrder);
|
||||
const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
|
||||
visibleElements.has(node as HTMLSpanElement)
|
||||
);
|
||||
if (
|
||||
currentOrder.length === desiredOrder.length &&
|
||||
currentOrder.every((node, index) => node === desiredOrder[index])
|
||||
)
|
||||
return;
|
||||
|
||||
let reference: ChildNode | null = currentOrder[0] ?? null;
|
||||
for (const element of desiredOrder) {
|
||||
if (element !== reference) parent.insertBefore(element, reference);
|
||||
const childNodes = Array.from(parent.childNodes);
|
||||
const elementIndex = childNodes.indexOf(element);
|
||||
reference =
|
||||
childNodes.slice(elementIndex + 1).find((node) => visibleElements.has(node as HTMLSpanElement)) ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
render(parts) {
|
||||
if (disposed) return;
|
||||
|
||||
const visibleItems: RenderedItem[] = [];
|
||||
const visibleKeys = new Set<string>();
|
||||
for (const part of parts) {
|
||||
let item = items.get(part.key);
|
||||
if (!item) item = createItem(part.key);
|
||||
if (item.element.classList.contains(INACTIVE_CLASS)) item.widestValueLength = 0;
|
||||
updateItem(item, part);
|
||||
setInactive(item.element, false);
|
||||
visibleItems.push(item);
|
||||
visibleKeys.add(part.key);
|
||||
}
|
||||
|
||||
for (const [key, item] of items) {
|
||||
if (visibleKeys.has(key)) continue;
|
||||
setInactive(item.element, true);
|
||||
}
|
||||
|
||||
reorderIfNeeded(visibleItems);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const item of items.values()) {
|
||||
if (item.element.parentNode === parent) parent.removeChild(item.element);
|
||||
}
|
||||
items.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createStatusBarRenderer, type StatusBarPart } from "./StatusBarRenderer.ts";
|
||||
|
||||
type TestNode = TestElement | TestTextNode;
|
||||
|
||||
class TestDocument {
|
||||
mutations = 0;
|
||||
|
||||
createElement(tagName: string): TestElement {
|
||||
return new TestElement(this, tagName);
|
||||
}
|
||||
|
||||
createTextNode(data: string): TestTextNode {
|
||||
return new TestTextNode(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
class TestTextNode {
|
||||
readonly nodeType = 3;
|
||||
parentNode: TestElement | undefined;
|
||||
private currentData: string;
|
||||
|
||||
constructor(
|
||||
private readonly document: TestDocument,
|
||||
data: string
|
||||
) {
|
||||
this.currentData = data;
|
||||
}
|
||||
|
||||
get data(): string {
|
||||
return this.currentData;
|
||||
}
|
||||
|
||||
set data(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentData = value;
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.currentData;
|
||||
}
|
||||
}
|
||||
|
||||
class TestStyle {
|
||||
private properties = new Map<string, string>();
|
||||
|
||||
constructor(private readonly document: TestDocument) {}
|
||||
|
||||
getPropertyValue(name: string): string {
|
||||
return this.properties.get(name) ?? "";
|
||||
}
|
||||
|
||||
setProperty(name: string, value: string) {
|
||||
this.document.mutations++;
|
||||
this.properties.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
class TestElement {
|
||||
readonly nodeType = 1;
|
||||
readonly ownerDocument: Document;
|
||||
readonly childNodes: TestNode[] = [];
|
||||
parentNode: TestElement | undefined;
|
||||
readonly style: TestStyle;
|
||||
private currentClassName = "";
|
||||
private currentHidden = false;
|
||||
private currentTitle = "";
|
||||
private attributes = new Map<string, string>();
|
||||
readonly classList = {
|
||||
contains: (token: string): boolean => this.currentClassName.split(" ").includes(token),
|
||||
toggle: (token: string, force: boolean): boolean => {
|
||||
const tokens = this.currentClassName.split(" ").filter(Boolean);
|
||||
const present = tokens.includes(token);
|
||||
if (present !== force) {
|
||||
this.className = force
|
||||
? [...tokens, token].join(" ")
|
||||
: tokens.filter((item) => item !== token).join(" ");
|
||||
}
|
||||
return force;
|
||||
},
|
||||
};
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string) {
|
||||
this.document.mutations++;
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
|
||||
removeAttribute(name: string) {
|
||||
if (this.attributes.delete(name)) this.document.mutations++;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly document: TestDocument,
|
||||
readonly tagName: string
|
||||
) {
|
||||
this.ownerDocument = document as unknown as Document;
|
||||
this.style = new TestStyle(document);
|
||||
}
|
||||
|
||||
get className(): string {
|
||||
return this.currentClassName;
|
||||
}
|
||||
|
||||
set className(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentClassName = value;
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.currentHidden;
|
||||
}
|
||||
|
||||
set hidden(value: boolean) {
|
||||
this.document.mutations++;
|
||||
this.currentHidden = value;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.currentTitle;
|
||||
}
|
||||
|
||||
set title(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentTitle = value;
|
||||
}
|
||||
|
||||
get children(): TestElement[] {
|
||||
return this.childNodes.filter((node): node is TestElement => node instanceof TestElement);
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.childNodes.map((node) => node.textContent ?? "").join("");
|
||||
}
|
||||
|
||||
createSpan(): TestElement {
|
||||
return this.appendChild(this.document.createElement("span"));
|
||||
}
|
||||
|
||||
setCssProps(properties: Record<string, string>) {
|
||||
for (const [name, value] of Object.entries(properties)) this.style.setProperty(name, value);
|
||||
}
|
||||
|
||||
appendChild<T extends TestNode>(node: T): T {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
this.childNodes.push(node);
|
||||
node.parentNode = this;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
|
||||
insertBefore<T extends TestNode>(node: T, reference: TestNode | null): T {
|
||||
if (node === reference) return node;
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
const index = reference ? this.childNodes.indexOf(reference) : -1;
|
||||
if (index < 0) {
|
||||
this.childNodes.push(node);
|
||||
} else {
|
||||
this.childNodes.splice(index, 0, node);
|
||||
}
|
||||
node.parentNode = this;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
|
||||
removeChild<T extends TestNode>(node: T): T {
|
||||
const index = this.childNodes.indexOf(node);
|
||||
if (index < 0) throw new Error("Node is not a child");
|
||||
this.childNodes.splice(index, 1);
|
||||
node.parentNode = undefined;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const document = new TestDocument();
|
||||
const root = new TestElement(document, "div");
|
||||
return { document, root, parent: root as unknown as HTMLElement };
|
||||
}
|
||||
|
||||
function findItem(root: TestElement, key: string): TestElement {
|
||||
const item = root.children.find((child) => child.className.split(" ").includes("livesync-stat-" + key));
|
||||
if (!item) throw new Error("Missing item: " + key);
|
||||
return item;
|
||||
}
|
||||
|
||||
function findValue(item: TestElement): TestElement {
|
||||
const value = item.children.find((child) => child.className.split(" ").includes("livesync-stat-value"));
|
||||
if (!value) throw new Error("Missing numeric value");
|
||||
return value;
|
||||
}
|
||||
|
||||
function visibleItems(root: TestElement): TestElement[] {
|
||||
return root.children.filter(
|
||||
(child) => !child.classList.contains("livesync-stat-inactive") && child.classList.contains("livesync-stat-item")
|
||||
);
|
||||
}
|
||||
|
||||
describe("createStatusBarRenderer", () => {
|
||||
it("renders complete text safely and gives each item an independent class hook", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([
|
||||
{ key: "sync", text: "<sync>" },
|
||||
{ key: "queue", text: " (queued: ", value: 2, suffix: ")" },
|
||||
]);
|
||||
|
||||
const sync = findItem(root, "sync");
|
||||
const queue = findItem(root, "queue");
|
||||
expect(sync.className.split(" ")).toEqual(["livesync-stat-item", "livesync-stat-sync"]);
|
||||
expect(sync.textContent).toBe("<sync>");
|
||||
expect(queue.className.split(" ")).toContain("livesync-stat-queue");
|
||||
expect(queue.textContent).toBe(" (queued: 2)");
|
||||
expect(findValue(queue).className).toBe("livesync-stat-value");
|
||||
|
||||
renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]);
|
||||
expect(sync.classList.contains("livesync-stat-inactive")).toBe(true);
|
||||
expect(sync.hidden).toBe(false);
|
||||
expect(sync.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(queue.classList.contains("livesync-stat-inactive")).toBe(false);
|
||||
expect(queue.textContent).toBe(" (queued: 3)");
|
||||
renderer.render([{ key: "queue", text: "Queue idle" }]);
|
||||
expect(queue.textContent).toBe("Queue idle");
|
||||
expect(findValue(queue).hidden).toBe(true);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("does not mutate the DOM when the same parts are rendered again", () => {
|
||||
const { document, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
const parts: StatusBarPart[] = [
|
||||
{ key: "pending", text: " pending ", value: 7, suffix: " files", title: "Pending" },
|
||||
];
|
||||
|
||||
renderer.render(parts);
|
||||
const mutations = document.mutations;
|
||||
renderer.render(parts);
|
||||
|
||||
expect(document.mutations).toBe(mutations);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("updates values while retaining the widest visible numeric width", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 9, suffix: ")" }]);
|
||||
const item = findItem(root, "count");
|
||||
const value = findValue(item);
|
||||
expect(item.textContent).toBe("(9)");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 123, suffix: ")" }]);
|
||||
expect(value.textContent).toBe("123");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 4, suffix: ")" }]);
|
||||
expect(value.textContent).toBe("4");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("retains omitted content and width, then resets width when the same item returns", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([{ key: "count", text: " ", value: 1234 }]);
|
||||
const item = findItem(root, "count");
|
||||
const value = findValue(item);
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([]);
|
||||
expect(item.classList.contains("livesync-stat-inactive")).toBe(true);
|
||||
expect(item.hidden).toBe(false);
|
||||
expect(item.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(item.textContent).toBe(" 1234");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([{ key: "count", text: " ", value: 5 }]);
|
||||
expect(findItem(root, "count")).toBe(item);
|
||||
expect(item.classList.contains("livesync-stat-inactive")).toBe(false);
|
||||
expect(item.getAttribute("aria-hidden")).toBeNull();
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("keeps inactive items in place when other items appear and does not repeat inactive mutations", () => {
|
||||
const { document, root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
const first = { key: "first", text: "first " };
|
||||
const count = { key: "count", text: "count ", value: 1234 };
|
||||
const last = { key: "last", text: "last" };
|
||||
renderer.render([first, count, last]);
|
||||
const initialOrder = [...root.children];
|
||||
const omitted = findItem(root, "count");
|
||||
|
||||
renderer.render([first, last]);
|
||||
expect(root.children.map((item) => initialOrder.indexOf(item))).toEqual([0, 1, 2]);
|
||||
expect(visibleItems(root)).toEqual([initialOrder[0], initialOrder[2]]);
|
||||
const mutations = document.mutations;
|
||||
renderer.render([first, last]);
|
||||
expect(document.mutations).toBe(mutations);
|
||||
|
||||
const added = { key: "added", text: "added " };
|
||||
renderer.render([first, added, last]);
|
||||
expect(root.children.map((item) => item.textContent)).toEqual(["first ", "count 1234", "added ", "last"]);
|
||||
expect(omitted.textContent).toBe("count 1234");
|
||||
expect(findValue(omitted).style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([first, count, added, last]);
|
||||
expect(findItem(root, "count")).toBe(omitted);
|
||||
expect(visibleItems(root)).toEqual(root.children);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("reorders existing nodes without replacing them", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
renderer.render([
|
||||
{ key: "first", text: "first " },
|
||||
{ key: "second", text: "second " },
|
||||
]);
|
||||
const first = findItem(root, "first");
|
||||
const second = findItem(root, "second");
|
||||
|
||||
renderer.render([
|
||||
{ key: "second", text: "second " },
|
||||
{ key: "first", text: "first " },
|
||||
]);
|
||||
|
||||
expect(visibleItems(root)).toEqual([second, first]);
|
||||
expect(findItem(root, "first")).toBe(first);
|
||||
expect(findItem(root, "second")).toBe(second);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("removes its nodes on dispose and ignores later renders", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const externalNode = new TestElement(new TestDocument(), "strong");
|
||||
root.appendChild(externalNode);
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
renderer.render([{ key: "sync", text: "sync" }]);
|
||||
const item = findItem(root, "sync");
|
||||
|
||||
renderer.dispose();
|
||||
const childCountAfterDispose = root.childNodes.length;
|
||||
renderer.dispose();
|
||||
renderer.render([{ key: "later", text: "later" }]);
|
||||
|
||||
expect(item.parentNode).toBeUndefined();
|
||||
expect(root.childNodes).toEqual([externalNode]);
|
||||
expect(root.childNodes).toHaveLength(childCountAfterDispose);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user