diff --git a/src/editor/lsp/codeLens.test.ts b/src/editor/lsp/codeLens.test.ts index 750d4b5c..69b5be68 100644 --- a/src/editor/lsp/codeLens.test.ts +++ b/src/editor/lsp/codeLens.test.ts @@ -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>(); ipc.docOutline.mockResolvedValue(ok(Array.from({ length: 8 }, (_, i) => item(`f${i}`, "function", i)))); diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts index 222ae821..8f7d136e 100644 --- a/src/editor/lsp/codeLens.ts +++ b/src/editor/lsp/codeLens.ts @@ -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(); + 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(); } diff --git a/src/editor/lsp/hint.test.ts b/src/editor/lsp/hint.test.ts index 56b6d6f4..fedff076 100644 --- a/src/editor/lsp/hint.test.ts +++ b/src/editor/lsp/hint.test.ts @@ -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" } }); diff --git a/src/editor/lsp/hint.ts b/src/editor/lsp/hint.ts index 715605ee..8db8656a 100644 --- a/src/editor/lsp/hint.ts +++ b/src/editor/lsp/hint.ts @@ -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);