Files
obsidian-livesync/src/modules/features/StatusBarDisplay.ts
T
vorotamoroz a8b9a901e4 Show initial and retrying Chunk fetches separately
Display disjoint pending counts in the status bar and cover atomic updates and disposal. Add a real Obsidian and CouchDB scenario for delayed arrival, terminal absence, and finite-replication completion.

Document retry scheduling and pending-count semantics. This uses the new Commonlib chunkFetchCounts export; the exact package dependency will be updated after its publication.
2026-09-30 08:48:18 +00:00

181 lines
5.7 KiB
TypeScript

import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
export type DisposableReactiveValue<T> = ReactiveValue<T> & {
dispose(): void;
};
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
return {
get value() {
return value.value;
},
onChanged(handler) {
value.onChanged(handler);
},
offChanged(handler) {
value.offChanged(handler);
},
dispose,
};
}
/**
* Mirrors an activity count while keeping each visible period on screen for a
* minimum total lifetime. The delay applies only when the source becomes zero.
*/
export function createMinimumVisibleActivityCount(
source: ReactiveValue<number>,
minimumVisibleMs: number
): DisposableReactiveValue<number> {
const minimumLifetime = Math.max(0, minimumVisibleMs);
const displayed = reactiveSource(Math.max(0, source.value));
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
let hideTimer: number | undefined;
let disposed = false;
const cancelHide = () => {
if (hideTimer === undefined) return;
compatGlobal.clearTimeout(hideTimer);
hideTimer = undefined;
};
const hideIfIdle = () => {
hideTimer = undefined;
if (disposed || Math.max(0, source.value) > 0) return;
displayed.value = 0;
visibleSince = undefined;
};
const update = () => {
if (disposed) return;
const nextCount = Math.max(0, source.value);
cancelHide();
if (nextCount > 0) {
if (displayed.value === 0) {
visibleSince = Date.now();
}
displayed.value = nextCount;
return;
}
if (displayed.value === 0) {
visibleSince = undefined;
return;
}
const elapsed = Date.now() - (visibleSince ?? Date.now());
const remaining = Math.max(0, minimumLifetime - elapsed);
if (remaining === 0) {
hideIfIdle();
} else {
hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining);
}
};
source.onChanged(update);
return asDisposableReactiveValue(displayed, () => {
if (disposed) return;
disposed = true;
cancelHide();
source.offChanged(update);
});
}
/**
* Formats a counter with a stable width and briefly retains its zero value so
* that the completion of queued work remains visible.
*/
export function createPaddedCounterLabel(
source: ReactiveValue<number>,
mark: string,
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
): DisposableReactiveValue<string> {
const linger = Math.max(0, inactiveLingerMs);
const formatted = reactiveSource("");
let maximumLength = 1;
let clearTimer: number | undefined;
let disposed = false;
const cancelClear = () => {
if (clearTimer === undefined) return;
compatGlobal.clearTimeout(clearTimer);
clearTimer = undefined;
};
const format = (count: number) => {
const requiredLength = `${Math.abs(count)}`.length + 1;
maximumLength = Math.max(maximumLength, requiredLength);
return ` ${mark}${`${STATUS_COUNTER_PADDING}${count}`.slice(-maximumLength)}`;
};
const update = () => {
if (disposed) return;
cancelClear();
const count = source.value;
formatted.value = format(count);
if (count !== 0) return;
clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined;
if (disposed) return;
formatted.value = "";
maximumLength = 1;
}, linger);
};
source.onChanged(update);
return asDisposableReactiveValue(formatted, () => {
if (disposed) return;
disposed = true;
cancelClear();
source.offChanged(update);
});
}
/**
* Displays the disjoint initial and retry chunk-fetch counts with the same
* padding and inactive linger behaviour as the other status counters.
*/
export function createChunkFetchCounterLabel(
source: ReactiveValue<{ initial: number; retrying: number }>
): DisposableReactiveValue<string> {
const initialCount = reactiveSource(0);
const retryingCount = reactiveSource(0);
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁");
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
let updatingCounts = false;
let disposed = false;
const updateLabel = () => {
if (updatingCounts || disposed) return;
formatted.value = `${initialLabel.value}${retryingLabel.value}`;
};
initialLabel.onChanged(updateLabel);
retryingLabel.onChanged(updateLabel);
const updateCounts = () => {
if (disposed) return;
updatingCounts = true;
try {
initialCount.value = source.value.initial;
retryingCount.value = source.value.retrying;
} finally {
updatingCounts = false;
updateLabel();
}
};
source.onChanged(updateCounts);
updateCounts();
return asDisposableReactiveValue(formatted, () => {
if (disposed) return;
disposed = true;
source.offChanged(updateCounts);
initialLabel.offChanged(updateLabel);
retryingLabel.offChanged(updateLabel);
initialLabel.dispose();
retryingLabel.dispose();
});
}