mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-02 23:42:31 +00:00
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.
181 lines
5.7 KiB
TypeScript
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();
|
|
});
|
|
}
|