mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-28 20:13:21 +00:00
Show CouchDB comparison progress in customisable status items
This commit is contained in:
1 parent
db15bcd5f2
commit
68dc40ff05
14 files changed
+1837
-72
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);
|
||||
|
||||
@@ -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 fastForwardSuffix = 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 });
|
||||
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);
|
||||
if (visibleFastForwardCount !== undefined) {
|
||||
parts.push({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
value: visibleFastForwardCount,
|
||||
suffix: ")",
|
||||
title: "Compared CouchDB feed entries",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
icon,
|
||||
message:
|
||||
networkActivity +
|
||||
"Sync: " +
|
||||
icon +
|
||||
" ↑ " +
|
||||
sent +
|
||||
pushRemaining.text +
|
||||
" ↓ " +
|
||||
arrived +
|
||||
pullRemaining.text +
|
||||
fastForwardSuffix,
|
||||
parts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
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: ⚡ ↑ 0 ↓ 7 (⏩ 1200)");
|
||||
expect(transferred.parts[transferred.parts.length - 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: ⚡ ↑ 0 ↓ 7 (⏩ 1200)");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,151 @@
|
||||
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>();
|
||||
const managedElements = new Set<HTMLSpanElement>();
|
||||
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 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);
|
||||
managedElements.add(element);
|
||||
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 currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
|
||||
managedElements.has(node as HTMLSpanElement)
|
||||
);
|
||||
const visibleElements = new Set(visibleItems.map((item) => item.element));
|
||||
const desiredOrder = [
|
||||
...visibleItems.map((item) => item.element),
|
||||
...currentOrder.filter((element) => !visibleElements.has(element)),
|
||||
];
|
||||
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) => managedElements.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);
|
||||
updateItem(item, part);
|
||||
setHidden(item.element, false);
|
||||
visibleItems.push(item);
|
||||
visibleKeys.add(part.key);
|
||||
}
|
||||
|
||||
for (const [key, item] of items) {
|
||||
if (visibleKeys.has(key)) continue;
|
||||
setHidden(item.element, true);
|
||||
setWidth(item.value, "");
|
||||
item.widestValueLength = 0;
|
||||
}
|
||||
|
||||
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();
|
||||
managedElements.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
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 = "";
|
||||
|
||||
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.hidden && child.className.split(" ").includes("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.hidden).toBe(true);
|
||||
expect(queue.hidden).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("resets numeric width while omitted and reuses the same item when it 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.hidden).toBe(true);
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("");
|
||||
|
||||
renderer.render([{ key: "count", text: " ", value: 5 }]);
|
||||
expect(findItem(root, "count")).toBe(item);
|
||||
expect(item.hidden).toBe(false);
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
||||
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