fix(editor): show usage counts when the project finishes loading at launch (#1809)

Usage counts asked while a language server loads the project come back incomplete and were re-asked only after a busy spell of 1.5 s or more, seen from the editor's own mount. An editor restored at launch mounts while its server starts, so it missed the spell's start, and a short load (vtsls loads a small project in about a second) never counted. Server spells are now followed once from app start, and the spell right after a start counts as loading whatever its length; later short spells are still ignored so request progress (jdtls) doesn't loop. The implementations gutter shares the same settle signal.

Co-authored-by: jaw52 <[email protected]>
This commit is contained in:
Jaw authored and GitHub committed 2026-11-07 09:42:29 +08:00
1 parent 39f6162c12
commit daac8e80b0
2 files changed
+123 -31

No files matched your search

+62 -13
View File
@@ -16,46 +16,95 @@ vi.mock("@/platform/ipc", async (orig) => {
const { onServerSettled, featureBackoff, installAvailabilityEvents } = await import("./availability");
const status = (s: string) => {
for (const fn of listeners) fn({ type: "languageServer", server: 1, status: s } as unknown as IdeEvent);
const status = (s: string, server = 1) => {
for (const fn of [...listeners]) fn({ type: "languageServer", server, status: s } as unknown as IdeEvent);
};
describe("onServerSettled", () => {
// Spells are followed once for the whole app: each test uses its own server.
const offs: (() => void)[] = [];
const settled = (fn: () => void) => offs.push(onServerSettled(fn));
afterEach(() => {
vi.useRealTimers();
listeners.clear();
for (const off of offs.splice(0)) off();
});
it("re-runs a callback that keeps the server busy only once", () => {
vi.useFakeTimers();
const fn = vi.fn();
onServerSettled(fn);
status("starting");
status("ready");
settled(fn);
status("starting", 1);
status("ready", 1);
expect(fn).toHaveBeenCalledTimes(1);
for (let i = 0; i < 2; i++) {
status("busy");
status("busy", 1);
vi.advanceTimersByTime(2000);
status("ready");
status("ready", 1);
}
expect(fn).toHaveBeenCalledTimes(2);
// Indexing that starts later is a new settle.
vi.advanceTimersByTime(10_000);
status("busy");
status("busy", 1);
vi.advanceTimersByTime(5000);
status("ready");
status("ready", 1);
expect(fn).toHaveBeenCalledTimes(3);
});
it("ignores short busy spells", () => {
vi.useFakeTimers();
const fn = vi.fn();
onServerSettled(fn);
status("busy");
settled(fn);
status("busy", 2);
vi.advanceTimersByTime(100);
status("ready");
status("ready", 2);
expect(fn).not.toHaveBeenCalled();
});
it("re-runs after the project loads right after a start, however briefly", () => {
vi.useFakeTimers();
const fn = vi.fn();
settled(fn);
status("starting", 3);
status("ready", 3);
expect(fn).toHaveBeenCalledTimes(1);
// vtsls loads a small project in about a second; answers given meanwhile were partial.
status("busy", 3);
vi.advanceTimersByTime(1000);
status("ready", 3);
expect(fn).toHaveBeenCalledTimes(2);
// Later short spells are requests again.
status("busy", 3);
vi.advanceTimersByTime(100);
status("ready", 3);
expect(fn).toHaveBeenCalledTimes(2);
});
it("tells a callback added while the project loads that it has", () => {
vi.useFakeTimers();
settled(() => {});
status("starting", 4);
status("ready", 4);
status("busy", 4);
// An editor restored at launch mounts now.
const fn = vi.fn();
settled(fn);
vi.advanceTimersByTime(800);
status("ready", 4);
expect(fn).toHaveBeenCalledTimes(1);
});
it("ignores a short spell that begins well after a start", () => {
vi.useFakeTimers();
const fn = vi.fn();
settled(fn);
status("starting", 5);
status("ready", 5);
vi.advanceTimersByTime(10_000);
status("busy", 5);
vi.advanceTimersByTime(100);
status("ready", 5);
expect(fn).toHaveBeenCalledTimes(1);
});
});
describe("featureBackoff", () => {
+61 -18
View File
@@ -68,38 +68,81 @@ const LONG_BUSY_MS = 1500;
/** A busy spell starting this soon after `fn` ran may be `fn`'s own requests. */
const ECHO_MS = 2000;
/** A busy spell starting this soon after a start is the server loading the project. */
const LOADING_AFTER_START_MS = 5000;
type Settle = (server: number, spellStart: number, loading: boolean) => void;
/** Servers starting (`-Infinity`) or busy since when. */
const spells = new Map<number, number>();
/** When servers became ready after starting, until their next spell ends. */
const readyAfterStart = new Map<number, number>();
const settles = new Set<Settle>();
let tracking = false;
/**
* Follows every server's spells from app start, so an editor that mounts while its
* server starts or loads (one restored at launch) still learns when that ends.
*/
function trackSpells() {
if (tracking || !isTauri()) return;
tracking = true;
onIdeEvent("languageServer", (e) => {
if (e.status === "starting") {
spells.set(e.server, -Infinity);
readyAfterStart.delete(e.server);
} else if (e.status === "busy") {
if (!spells.has(e.server)) spells.set(e.server, Date.now());
} else if (e.status === "ready") {
const t = spells.get(e.server);
spells.delete(e.server);
if (t === undefined) return;
const startedAt = readyAfterStart.get(e.server);
if (t === -Infinity) readyAfterStart.set(e.server, Date.now());
else readyAfterStart.delete(e.server);
const loading = startedAt !== undefined && t - startedAt < LOADING_AFTER_START_MS;
for (const settle of settles) settle(e.server, t, loading);
} else {
spells.delete(e.server);
readyAfterStart.delete(e.server);
}
});
}
/**
* Calls `fn` when a server becomes ready after starting or after a long busy
* spell (indexing), when earlier answers may have been incomplete. Short busy
* spells are ignored: jdtls reports progress for the very requests a caller
* sends, and re-asking on each of them would never stop. For the same reason
* a long spell that began right after `fn` ran (slow requests `fn` sent, or
* indexing right after start) re-runs it only once in a row.
* indexing right after start) re-runs it only once in a row. The spell right
* after a start counts whatever its length: it is the project loading (vtsls
* loads a small one in about a second), and answers given meanwhile are partial.
*/
export function onServerSettled(fn: () => void): () => void {
if (!isTauri()) return () => {};
const since = new Map<number, number>();
trackSpells();
const fired = new Map<number, { at: number; echoed: boolean }>();
return onIdeEvent("languageServer", (e) => {
if (e.status === "starting") {
since.set(e.server, -Infinity);
fired.delete(e.server);
} else if (e.status === "busy") {
if (!since.has(e.server)) since.set(e.server, Date.now());
} else if (e.status === "ready") {
const t = since.get(e.server);
since.delete(e.server);
if (t === undefined || Date.now() - t < LONG_BUSY_MS) return;
const last = fired.get(e.server);
const echo = !!last && t - last.at < ECHO_MS;
if (echo && last.echoed) return;
fired.set(e.server, { at: Date.now(), echoed: echo });
fn();
} else since.delete(e.server);
const settle: Settle = (server, t, loading) => {
if (!loading && Date.now() - t < LONG_BUSY_MS) return;
const last = fired.get(server);
const echo = !!last && t - last.at < ECHO_MS;
if (echo && last.echoed) return;
fired.set(server, { at: Date.now(), echoed: echo });
fn();
};
settles.add(settle);
const offStarting = onIdeEvent("languageServer", (e) => {
if (e.status === "starting") fired.delete(e.server);
});
return () => {
settles.delete(settle);
offStarting();
};
}
export function installAvailabilityEvents(): () => void {
trackSpells();
return onIdeEvent("languageServer", (e) => {
if (e.status === "ready") clearBackoffs();
});