fix(lsp): retry incomplete counts and preserve newer hints

This commit is contained in:
l0ng-ai
2026-10-29 20:36:19 +08:00
parent a8b43c27f2
commit a8a63a40c8
4 changed files with 54 additions and 3 deletions
+24
View File
@@ -130,6 +130,30 @@ describe("background usage counts", () => {
expect(titles(view)).toEqual(["2 usages"]);
});
it("retries incomplete counts when indexing ends without a code-lens refresh", async () => {
ipc.lspReferences.mockResolvedValueOnce(refs(0, "indexing"));
const view = editor();
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "busy", message: null });
await settle();
expect(titles(view)).toEqual([]);
await settle();
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "ready", message: null });
await settle();
expect(ipc.lspReferences).toHaveBeenCalledTimes(2);
expect(titles(view)).toEqual(["2 usages"]);
});
it("does not repeat complete counts when the server finishes other work", async () => {
const view = editor();
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "busy", message: null });
await settle();
await settle();
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "ready", message: null });
await settle();
expect(ipc.lspReferences).toHaveBeenCalledTimes(1);
expect(titles(view)).toEqual(["2 usages"]);
});
it("cancels all in-flight requests on destroy without starting the next batch", async () => {
const pending = deferred<ReturnType<typeof refs>>();
ipc.docOutline.mockResolvedValue(ok(Array.from({ length: 8 }, (_, i) => item(`f${i}`, "function", i))));
+12 -2
View File
@@ -13,7 +13,7 @@ import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolK
import { useProject } from "@/platform/project";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { posToOffset } from "./positions";
import { featureBackoff, onLspRefresh } from "./availability";
import { featureBackoff, onLspRefresh, onServerSettled } from "./availability";
import { editorPrefs } from "@/platform/editorSettings";
import { authorLabel, authorSummary, authorTooltip, blameFor, onBlameInvalidated } from "./authorLens";
import { tx } from "@/platform/i18n";
@@ -113,8 +113,10 @@ class UsagesLensPlugin {
private outline: { doc: Text; targets: LensTarget[] } | null = null;
private generation = 0;
private pending = new Set<RequestId>();
private incomplete = false;
private unsubscribe: () => void;
private offReady: () => void;
private offSettled: () => void;
private offBlame: () => void;
constructor(private view: EditorView) {
@@ -127,6 +129,9 @@ class UsagesLensPlugin {
this.cache.clear();
this.refresh();
});
this.offSettled = onServerSettled(() => {
if (this.incomplete) this.refresh();
});
this.offBlame = onBlameInvalidated(() => this.refresh());
}
@@ -159,6 +164,7 @@ class UsagesLensPlugin {
}
const text = view.state.doc;
const wanted = () => gen === this.generation && view.state.doc === text;
this.incomplete = false;
// Blame runs beside the reference requests; it never holds up the counts.
const blame = authors ? blameFor(doc.path, text) : Promise.resolve(null);
if (this.cacheDoc !== text) {
@@ -191,7 +197,10 @@ class UsagesLensPlugin {
// Only an explicit Find Usages should widen the server's scope.
return commands.lspReferences(request, doc.doc, { line: t.line, col: t.col }, false, false);
}, wanted);
if (wanted() && !incomplete) this.cache.set(key(t), refs.length);
if (wanted()) {
if (incomplete) this.incomplete = true;
else this.cache.set(key(t), refs.length);
}
} catch (e) {
if (wanted() && isUnavailable(e)) unavailable = true;
} finally {
@@ -230,6 +239,7 @@ class UsagesLensPlugin {
this.schedule.cancel();
this.unsubscribe();
this.offReady();
this.offSettled();
this.offBlame();
this.cancel();
}
+10
View File
@@ -40,6 +40,16 @@ describe("pending hints", () => {
expect(hint()).toBe("New error");
});
it("does not overwrite a newer hint while waiting to show progress", () => {
const done = showPendingHint(view, "Waiting…");
vi.advanceTimersByTime(100);
showHint(view, "New error", { tone: "error" });
vi.advanceTimersByTime(150);
expect(hint()).toBe("New error");
done();
expect(hint()).toBe("New error");
});
it.each(["edit", "move", "destroy"])("does not show stale progress after %s", (change) => {
const done = showPendingHint(view, "Waiting…");
if (change === "edit") view.dispatch({ changes: { from: 0, insert: "x" } });
+8 -1
View File
@@ -75,9 +75,16 @@ export function showHint(
/** Show progress only for slow requests; the disposer removes only its own hint. */
export function showPendingHint(view: EditorView, content: string): () => void {
const { doc, selection } = view.state;
const previous = view.state.field(hintField, false);
let shown: Hint | null = null;
const timer = setTimeout(() => {
if (!view.dom.isConnected || view.state.doc !== doc || !view.state.selection.eq(selection)) return;
if (
!view.dom.isConnected ||
view.state.doc !== doc ||
!view.state.selection.eq(selection) ||
view.state.field(hintField, false) !== previous
)
return;
showHint(view, content, { timeout: 30_000 });
shown = view.state.field(hintField);
}, 250);