mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-17 10:06:00 +00:00
feat: distinguish remote operation and request activity
This commit is contained in:
+1
-1
Submodule src/lib updated: 4639e4537f...09bfafbd6c
@@ -31,7 +31,12 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector.ts";
|
||||
import { hasRemoteActivity } from "./RemoteActivityStatus.ts";
|
||||
import {
|
||||
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
import { createMinimumVisibleActivityCount, createPaddedCounterLabel } from "./StatusBarDisplay.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@lib/common/LSError.ts";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
@@ -118,48 +123,43 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
p2pLogCollector = new P2PLogCollector();
|
||||
|
||||
observeForLogs() {
|
||||
const padSpaces = `\u{2007}`.repeat(10);
|
||||
// const emptyMark = `\u{2003}`;
|
||||
function padLeftSpComputed(numI: ReactiveValue<number>, mark: string) {
|
||||
const formatted = reactiveSource("");
|
||||
let timer: number | undefined = undefined;
|
||||
let maxLen = 1;
|
||||
numI.onChanged((numX) => {
|
||||
const num = numX.value;
|
||||
const numLen = `${Math.abs(num)}`.length + 1;
|
||||
maxLen = maxLen < numLen ? numLen : maxLen;
|
||||
if (timer) compatGlobal.clearTimeout(timer);
|
||||
if (num == 0) {
|
||||
timer = compatGlobal.setTimeout(() => {
|
||||
formatted.value = "";
|
||||
maxLen = 1;
|
||||
}, 3000);
|
||||
}
|
||||
formatted.value = ` ${mark}${`${padSpaces}${num}`.slice(-maxLen)}`;
|
||||
});
|
||||
return computed(() => formatted.value);
|
||||
}
|
||||
const labelReplication = padLeftSpComputed(this.services.replication.replicationResultCount, `📥`);
|
||||
const labelDBCount = padLeftSpComputed(this.services.replication.databaseQueueCount, `📄`);
|
||||
const labelStorageCount = padLeftSpComputed(this.services.replication.storageApplyingCount, `💾`);
|
||||
const labelChunkCount = padLeftSpComputed(collectingChunks, `🧩`);
|
||||
const labelPluginScanCount = padLeftSpComputed(pluginScanningCount, `🔌`);
|
||||
const labelConflictProcessCount = padLeftSpComputed(this.services.conflict.conflictProcessQueueCount, `🔩`);
|
||||
const registerDisplay = <T extends { dispose(): void }>(display: T): T => {
|
||||
this.plugin.register(() => display.dispose());
|
||||
return display;
|
||||
};
|
||||
const labelReplication = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.replication.replicationResultCount, `📥`)
|
||||
);
|
||||
const labelDBCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.replication.databaseQueueCount, `📄`)
|
||||
);
|
||||
const labelStorageCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.replication.storageApplyingCount, `💾`)
|
||||
);
|
||||
const labelChunkCount = registerDisplay(createPaddedCounterLabel(collectingChunks, `🧩`));
|
||||
const labelPluginScanCount = registerDisplay(createPaddedCounterLabel(pluginScanningCount, `🔌`));
|
||||
const labelConflictProcessCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.conflict.conflictProcessQueueCount, `🔩`)
|
||||
);
|
||||
const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value - hiddenFilesProcessingCount.value);
|
||||
const labelHiddenFilesCount = padLeftSpComputed(hiddenFilesCount, `⚙️`);
|
||||
const labelHiddenFilesCount = registerDisplay(createPaddedCounterLabel(hiddenFilesCount, `⚙️`));
|
||||
const queueCountLabelX = reactive(() => {
|
||||
return `${labelReplication()}${labelDBCount()}${labelStorageCount()}${labelChunkCount()}${labelPluginScanCount()}${labelHiddenFilesCount()}${labelConflictProcessCount()}`;
|
||||
return `${labelReplication.value}${labelDBCount.value}${labelStorageCount.value}${labelChunkCount.value}${labelPluginScanCount.value}${labelHiddenFilesCount.value}${labelConflictProcessCount.value}`;
|
||||
});
|
||||
const queueCountLabel = () => queueCountLabelX.value;
|
||||
|
||||
const trackedRequestCount = reactive(() => {
|
||||
return getTrackedRequestCount(this.services.API.requestCount.value, this.services.API.responseCount.value);
|
||||
});
|
||||
const displayedTrackedRequestCount = registerDisplay(
|
||||
createMinimumVisibleActivityCount(trackedRequestCount, REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS)
|
||||
);
|
||||
|
||||
const requestingStatLabel = computed(() => {
|
||||
return hasRemoteActivity(
|
||||
this.services.API.requestCount.value,
|
||||
this.services.API.responseCount.value,
|
||||
this.services.replicator.boundedRemoteActivityCount.value
|
||||
)
|
||||
? "📲 "
|
||||
: "";
|
||||
return formatRemoteActivityStatusLabel({
|
||||
remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
|
||||
trackedRequestCount: displayedTrackedRequestCount.value,
|
||||
});
|
||||
});
|
||||
|
||||
const replicationStatLabel = computed(() => {
|
||||
@@ -215,11 +215,11 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
}
|
||||
return { w, sent, pushLast, arrived, pullLast };
|
||||
});
|
||||
const labelProc = padLeftSpComputed(this.services.fileProcessing.processing, `⏳`);
|
||||
const labelPend = padLeftSpComputed(this.services.fileProcessing.totalQueued, `🛫`);
|
||||
const labelInBatchDelay = padLeftSpComputed(this.services.fileProcessing.batched, `📬`);
|
||||
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()}${labelPend()}${labelInBatchDelay()}`;
|
||||
return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
|
||||
});
|
||||
const statusLineLabel = computed(() => {
|
||||
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasRemoteActivity } from "./RemoteActivityStatus.ts";
|
||||
|
||||
describe("hasRemoteActivity", () => {
|
||||
it("preserves the existing HTTP request balance signal", () => {
|
||||
expect(hasRemoteActivity(2, 1, 0)).toBe(true);
|
||||
expect(hasRemoteActivity(2, 2, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports bounded remote activity without an HTTP request imbalance", () => {
|
||||
expect(hasRemoteActivity(2, 2, 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,27 @@
|
||||
/** Returns whether the status UI should report HTTP traffic or a finite remote operation in progress. */
|
||||
export function hasRemoteActivity(requestCount: number, responseCount: number, boundedRemoteActivityCount: number) {
|
||||
return requestCount - responseCount !== 0 || boundedRemoteActivityCount !== 0;
|
||||
/** Status icon for a finite remote operation whose lifetime is known. */
|
||||
export const REMOTE_OPERATION_ACTIVITY_ICON = "📲";
|
||||
|
||||
/** Status icon for approximate physical remote-request activity. */
|
||||
export const REMOTE_REQUEST_ACTIVITY_ICON = "🌐";
|
||||
|
||||
/** Avoids hiding very short remote requests before the status bar can render them. */
|
||||
export const REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS = 150;
|
||||
|
||||
export type RemoteActivityStatus = {
|
||||
remoteOperationCount: number;
|
||||
trackedRequestCount: number;
|
||||
};
|
||||
|
||||
/** Returns the non-negative difference between tracked request starts and completions. */
|
||||
export function getTrackedRequestCount(requestCount: number, responseCount: number): number {
|
||||
return Math.max(0, requestCount - responseCount);
|
||||
}
|
||||
|
||||
/** Formats the compact prefix shown before the replication status. */
|
||||
export function formatRemoteActivityStatusLabel(status: RemoteActivityStatus): string {
|
||||
const labels = [
|
||||
status.remoteOperationCount > 0 ? REMOTE_OPERATION_ACTIVITY_ICON : "",
|
||||
status.trackedRequestCount > 0 ? `${REMOTE_REQUEST_ACTIVITY_ICON}${status.trackedRequestCount}` : "",
|
||||
].filter((label) => label !== "");
|
||||
return labels.length > 0 ? `${labels.join(" ")} ` : "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
REMOTE_OPERATION_ACTIVITY_ICON,
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
|
||||
describe("getTrackedRequestCount", () => {
|
||||
it("reports the non-negative difference between starts and completions", () => {
|
||||
expect(getTrackedRequestCount(3, 2)).toBe(1);
|
||||
expect(getTrackedRequestCount(2, 2)).toBe(0);
|
||||
expect(getTrackedRequestCount(2, 3)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRemoteActivityStatusLabel", () => {
|
||||
it("separates a finite remote operation from tracked physical requests", () => {
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 1, trackedRequestCount: 0 })).toBe(
|
||||
`${REMOTE_OPERATION_ACTIVITY_ICON} `
|
||||
);
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 0, trackedRequestCount: 1 })).toBe(
|
||||
`${REMOTE_REQUEST_ACTIVITY_ICON}1 `
|
||||
);
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 1, trackedRequestCount: 2 })).toBe(
|
||||
`${REMOTE_OPERATION_ACTIVITY_ICON} ${REMOTE_REQUEST_ACTIVITY_ICON}2 `
|
||||
);
|
||||
});
|
||||
|
||||
it("omits inactive and invalid negative activity counts", () => {
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 0, trackedRequestCount: 0 })).toBe("");
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: -1, trackedRequestCount: -1 })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
|
||||
describe("createMinimumVisibleActivityCount", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-16T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps a short activity visible for the configured minimum lifetime", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createMinimumVisibleActivityCount(source, 150);
|
||||
const rendered = reactive(() => `active:${display.value}`);
|
||||
|
||||
expect(rendered.value).toBe("active:0");
|
||||
source.value = 1;
|
||||
expect(rendered.value).toBe("active:1");
|
||||
vi.advanceTimersByTime(50);
|
||||
source.value = 0;
|
||||
|
||||
expect(display.value).toBe(1);
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(display.value).toBe(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBe(0);
|
||||
expect(rendered.value).toBe("active:0");
|
||||
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("updates overlapping activity and starts a new minimum lifetime after becoming idle", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createMinimumVisibleActivityCount(source, 150);
|
||||
|
||||
source.value = 1;
|
||||
vi.advanceTimersByTime(25);
|
||||
source.value = 2;
|
||||
expect(display.value).toBe(2);
|
||||
source.value = 0;
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
source.value = 1;
|
||||
expect(display.value).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(75);
|
||||
source.value = 0;
|
||||
expect(display.value).toBe(0);
|
||||
|
||||
source.value = 3;
|
||||
source.value = 0;
|
||||
expect(display.value).toBe(3);
|
||||
vi.advanceTimersByTime(150);
|
||||
expect(display.value).toBe(0);
|
||||
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels pending work and stops observing its source when disposed", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createMinimumVisibleActivityCount(source, 150);
|
||||
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(150);
|
||||
source.value = 2;
|
||||
|
||||
expect(display.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPaddedCounterLabel", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps the widest counter label until its inactive linger period ends", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📥");
|
||||
|
||||
expect(display.value).toBe("");
|
||||
source.value = 9;
|
||||
expect(display.value).toBe(" 📥\u20079");
|
||||
source.value = 123;
|
||||
expect(display.value).toBe(" 📥\u2007123");
|
||||
source.value = 0;
|
||||
expect(display.value).toBe(" 📥\u2007\u2007\u20070");
|
||||
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
|
||||
expect(display.value).toBe(" 📥\u2007\u2007\u20070");
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBe("");
|
||||
|
||||
source.value = 7;
|
||||
expect(display.value).toBe(" 📥\u20077");
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels the pending clear when counter activity resumes", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📄");
|
||||
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
vi.advanceTimersByTime(1_000);
|
||||
source.value = 2;
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||
|
||||
expect(display.value).toBe(" 📄\u20072");
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels its inactive timer and source subscription when disposed", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📄");
|
||||
|
||||
source.value = 4;
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||
source.value = 5;
|
||||
|
||||
expect(display.value).toBe(" 📄\u20070");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user