Show CouchDB comparison progress in customisable status items

This commit is contained in:
vorotamoroz committed 2026-10-25 05:40:10 +00:00
1 parent db15bcd5f2
commit 68dc40ff05
14 files changed
+1837 -72

No files matched your search

+61 -68
View File
@@ -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();
});
});
+47 -4
View File
@@ -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, "📄");
+151
View File
@@ -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);
});
});