perf(java): prewarm configuration navigation and share results between hover and click (#947)

Configuration navigation from YAML/properties still waited for a cold index the first
time and repeated the lookup between the ⌘-hover probe and the click.

- Trusted projects with a Maven/Gradle/Eclipse build file start Java shortly after
  opening. Once JDT reports ServiceReady, the app calls the new
  openidea.java.configuration.warmup command: a low-priority background job builds
  the configuration index one project at a time (open YAML/properties modules first),
  waits for workspace builds, has a 30 s budget per project, and only fills free cache
  room.
- Index builds take a cancellable per-project lock instead of one global lock, so a
  background build never delays another project, and a warmed snapshot is dropped
  if the Java model changed meanwhile.
- In YAML/properties the editor underlines the whole key (hyphens and dots
  included), hover and Go to Definition share one bounded request per key, and the
  declaration chooser names the whole key.

Bundle java-usages 1.6.0 is rebuilt reproducibly with build.py against JDT LS
1.63.0-202610191322 (SHA-256 33cfa1bb…). The real-JDT harness checks that the first
lookup reuses the warmed index.

Co-authored-by: qjlqyf <[email protected]>
Цей коміт міститься в:
qiaojianlong authored and GitHub committed 2026-11-06 12:23:39 +08:00
1 джерело 8c381a07c4
коміт c4b87ae09e
22 files changed
+1038 -37

No files matched your search

+218
Переглянути файл
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { commands, Location } from "@/platform/ipc";
const lspGoto = vi.hoisted(() => vi.fn<typeof commands.lspGoto>());
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } };
});
const { emitLocalIdeEvent } = await import("@/platform/ipc");
const { _emitEditorEvent } = await import("@/editor/api");
const { lspDoc } = await import("./lsp");
const { navigationExtension } = await import("./navigation");
const { requestNavigation, invalidateConfigurationNavigation, installConfigurationNavigationEvents, navigationWord } =
await import("./configurationNavigation");
const target: Location = {
path: "/p/Config.java",
uri: null,
range: { start: { line: 3, col: 4 }, end: { line: 3, col: 9 } },
};
const views: EditorView[] = [];
function editor(doc = 1, language = "yaml", text = "server:\n port: 8080\n") {
const view = new EditorView({
state: EditorState.create({
doc: text,
extensions: [
lspDoc.of({ doc, path: `/p/${doc}.${language}`, language, uiMode: language, readOnly: false }),
navigationExtension(),
],
}),
parent: document.body,
});
views.push(view);
return view;
}
let off: () => void;
beforeEach(() => {
vi.useFakeTimers();
invalidateConfigurationNavigation();
lspGoto.mockReset().mockResolvedValue({ status: "ok", data: [target] });
off = installConfigurationNavigationEvents();
});
afterEach(() => {
off();
views.splice(0).forEach((v) => v.destroy());
vi.useRealTimers();
});
describe("configuration navigation result reuse", () => {
it.each(["yaml", "properties"])("%s names include separators but exclude syntax and adjacent values", (language) => {
const view = editor(1, language, ' "spring.datasource.driver-class-name": example.Driver');
for (const offset of [3, 9, 20, 26, 32, 35]) {
const range = navigationWord(view, offset)!;
expect(view.state.sliceDoc(range.from, range.to)).toBe("spring.datasource.driver-class-name");
}
expect(navigationWord(view, 1)).toBeNull();
expect(navigationWord(view, 39)).toBeNull();
});
it("shares an in-flight hover lookup with a click anywhere in the same key", async () => {
let resolve!: (r: Awaited<ReturnType<typeof commands.lspGoto>>) => void;
lspGoto.mockImplementationOnce(
() =>
new Promise((r) => {
resolve = r;
}),
);
const view = editor();
const hover = requestNavigation(view, 10, "definition");
const click = requestNavigation(view, 12, "definition");
expect(click).toBe(hover);
await vi.waitFor(() => expect(lspGoto).toHaveBeenCalledOnce());
resolve({ status: "ok", data: [target] });
expect(await click).toEqual([target]);
expect(await requestNavigation(view, 11, "definition")).toEqual([target]);
expect(lspGoto).toHaveBeenCalledOnce();
});
it("reuses results when the same document is shown again", async () => {
const view = editor();
await requestNavigation(view, 10, "definition");
await requestNavigation(editor(), 11, "definition");
expect(lspGoto).toHaveBeenCalledOnce();
});
it("keeps different keys and documents separate", async () => {
const view = editor();
await requestNavigation(view, 1, "definition");
await requestNavigation(view, 10, "definition");
await requestNavigation(editor(2), 10, "definition");
expect(lspGoto).toHaveBeenCalledTimes(3);
});
it.each(["java", "typescript"])("leaves %s native navigation uncached", async (language) => {
const view = editor(1, language, "value");
await requestNavigation(view, 1, "definition");
await requestNavigation(view, 1, "definition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
it("does not mix definition with other navigation kinds", async () => {
const view = editor();
await requestNavigation(view, 10, "definition");
await requestNavigation(view, 10, "typeDefinition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
it("invalidates on edits to another open Java document, before its IPC sync", async () => {
const view = editor();
const java = editor(2, "java", "class Config {}");
await requestNavigation(view, 10, "definition");
java.dispatch({ changes: { from: 6, to: 12, insert: "Changed" } });
await requestNavigation(view, 10, "definition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
it.each([
{ type: "fsChanged", created: [], modified: ["/p/Config.java"], removed: [] },
{ type: "fsChanged", created: [], modified: [], removed: ["/p/Config.java"] },
{ type: "docPatched", doc: 2, baseVersion: 1, version: 2, changes: [] },
{ type: "languageServer", server: 1, name: "jdtls", status: "starting", message: null },
{ type: "lspRefresh", server: 1, kind: "codeLens" },
{ type: "excludedDirsChanged", dirs: ["/p/target"] },
] as const)("invalidates on $type", async (event) => {
const view = editor();
await requestNavigation(view, 10, "definition");
emitLocalIdeEvent(event as Parameters<typeof emitLocalIdeEvent>[0]);
await requestNavigation(view, 10, "definition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
it("does not invalidate just because navigation opened a clean target", async () => {
const view = editor();
await requestNavigation(view, 10, "definition");
_emitEditorEvent({ type: "docOpened", doc: 2, path: target.path, language: "java" });
await requestNavigation(view, 10, "definition");
expect(lspGoto).toHaveBeenCalledOnce();
_emitEditorEvent({ type: "docClosed", doc: 2, path: target.path });
await requestNavigation(view, 10, "definition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
it("discards in-flight results when dependencies change", async () => {
let resolve!: (r: Awaited<ReturnType<typeof commands.lspGoto>>) => void;
lspGoto.mockImplementationOnce(
() =>
new Promise((r) => {
resolve = r;
}),
);
const view = editor();
const pending = requestNavigation(view, 10, "definition");
const rejected = expect(pending).rejects.toMatchObject({ kind: "cancelled" });
await vi.waitFor(() => expect(lspGoto).toHaveBeenCalledOnce());
invalidateConfigurationNavigation();
resolve({ status: "ok", data: [target] });
await rejected;
await requestNavigation(view, 10, "definition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
it.each(["settled", "refresh"])("does not swallow an in-flight click on its own server %s event", async (event) => {
let resolve!: (r: Awaited<ReturnType<typeof commands.lspGoto>>) => void;
lspGoto.mockImplementationOnce(
() =>
new Promise((r) => {
resolve = r;
}),
);
const view = editor();
const pending = requestNavigation(view, 10, "definition");
await vi.waitFor(() => expect(lspGoto).toHaveBeenCalledOnce());
if (event === "settled") {
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "jdtls", status: "busy", message: null });
vi.setSystemTime(Date.now() + 1600);
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "jdtls", status: "ready", message: null });
} else emitLocalIdeEvent({ type: "lspRefresh", server: 1, kind: "codeLens" });
resolve({ status: "ok", data: [target] });
expect(await pending).toEqual([target]);
await requestNavigation(view, 10, "definition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
it("does not retain errors or missing targets during import", async () => {
const view = editor();
lspGoto.mockResolvedValueOnce({ status: "error", error: { kind: "protocol", message: "timeout" } });
await expect(requestNavigation(view, 10, "definition")).rejects.toThrow("timeout");
lspGoto.mockResolvedValueOnce({ status: "ok", data: [] });
expect(await requestNavigation(view, 10, "definition")).toEqual([]);
expect(await requestNavigation(view, 10, "definition")).toEqual([target]);
expect(lspGoto).toHaveBeenCalledTimes(3);
});
it("expires results when a server did not report a change", async () => {
const view = editor();
await requestNavigation(view, 10, "definition");
vi.setSystemTime(Date.now() + 30_001);
await requestNavigation(view, 10, "definition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
it("bounds retained entries", async () => {
const view = editor(1, "properties", Array.from({ length: 130 }, (_, i) => `key${i}=x\n`).join(""));
for (let i = 1; i <= 130; i++) await requestNavigation(view, view.state.doc.line(i).from, "definition");
await requestNavigation(view, 0, "definition");
expect(lspGoto).toHaveBeenCalledTimes(131);
});
it("bounds retained locations even for large multi-target answers", async () => {
const view = editor();
lspGoto.mockResolvedValueOnce({ status: "ok", data: Array.from({ length: 4097 }, () => target) });
await requestNavigation(view, 10, "definition");
await requestNavigation(view, 10, "definition");
expect(lspGoto).toHaveBeenCalledTimes(2);
});
});
+160
Переглянути файл
@@ -0,0 +1,160 @@
/** Shared, bounded navigation results for YAML/properties hover and click. */
import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { onEditorEvent } from "@/editor/api";
import { commands, IpcError, onIdeEvent, type GotoKind, type Location } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { onServerSettled } from "./availability";
import { docOf, lspRequest } from "./lsp";
import { offsetToPos } from "./positions";
// Events provide invalidation; the age limit is a backstop for servers/remote
// file systems which do not report every semantic change. Empty/error responses
// are never retained, since import may still be in progress.
export const CONFIGURATION_NAVIGATION_MAX_AGE = 30_000;
const MAX_ENTRIES = 128;
const MAX_LOCATIONS = 4096;
interface Cached {
text: Text;
path: string;
at: number;
size: number;
retain: boolean;
pending: Promise<Location[]>;
}
const results = new Map<string, Cached>();
let revision = 0;
export function configurationNavigationRevision(): number {
return revision;
}
export function invalidateConfigurationNavigation() {
revision++;
results.clear();
}
export function isConfigurationDocument(view: EditorView): boolean {
const language = docOf(view)?.language;
return language === "yaml" || language === "properties";
}
/** Configuration names include hyphens and dots even when the editor's
* language word rules split them. Share this range between decoration and lookup. */
export function navigationWord(view: EditorView, offset: number): { from: number; to: number } | null {
if (!isConfigurationDocument(view)) return view.state.wordAt(offset);
const line = view.state.doc.lineAt(offset);
const nameChar = /[\p{L}\p{N}_.$-]/u;
let from = offset - line.from;
let to = from;
while (from > 0 && nameChar.test(line.text[from - 1]!)) from--;
while (to < line.text.length && nameChar.test(line.text[to]!)) to++;
return from < to ? { from: line.from + from, to: line.from + to } : null;
}
/** Indexing/refresh can be progress from this very lookup. Do not cancel an
* explicit click when its own server task finishes; simply don't retain that
* answer. Source edits/restarts still invalidate the request generation. */
function refreshConfigurationNavigation() {
for (const [key, entry] of results) {
entry.retain = false;
if (entry.size) results.delete(key);
}
}
export function hasConfigurationNavigation(view: EditorView, offset: number): boolean {
const doc = docOf(view);
const word = navigationWord(view, offset);
if (!doc || !word) return false;
const entry = results.get(`${doc.doc}:${word.from}:${word.to}`);
return (
!!entry &&
entry.size > 0 &&
entry.retain &&
entry.path === doc.path &&
entry.text.eq(view.state.doc) &&
Date.now() - entry.at < CONFIGURATION_NAVIGATION_MAX_AGE
);
}
export function requestNavigation(view: EditorView, offset: number, kind: GotoKind): Promise<Location[]> {
const doc = docOf(view)!;
const word = navigationWord(view, offset);
if (!isConfigurationDocument(view) || kind !== "definition" || !word)
return lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, offset), kind));
const key = `${doc.doc}:${word.from}:${word.to}`;
const text = view.state.doc;
const hit = results.get(key);
if (
hit &&
hit.path === doc.path &&
hit.text.eq(text) &&
Date.now() - hit.at < (hit.size ? CONFIGURATION_NAVIGATION_MAX_AGE : 3000)
) {
results.delete(key);
results.set(key, hit);
return hit.pending;
}
const version = revision;
const entry: Cached = {
text,
path: doc.path,
at: Date.now(),
size: 0,
retain: true,
pending: lspRequest(
() => commands.lspGoto(doc.doc, offsetToPos(text, word.from), kind),
() => revision === version,
)
.then((locations) => {
if (revision !== version || !view.state.doc.eq(text))
throw new IpcError({ kind: "cancelled", message: "cancelled" });
entry.size = locations.length;
if ((!locations.length || !entry.retain) && results.get(key) === entry) results.delete(key);
let size = 0;
for (const value of results.values()) size += value.size;
while (size > MAX_LOCATIONS && results.size) {
const oldest = results.keys().next().value!;
size -= results.get(oldest)!.size;
results.delete(oldest);
}
return locations;
})
.catch((error: unknown) => {
if (results.get(key) === entry) results.delete(key);
throw error;
}),
};
results.delete(key);
results.set(key, entry);
while (results.size > MAX_ENTRIES) results.delete(results.keys().next().value!);
return entry.pending;
}
export function installConfigurationNavigationEvents(): () => void {
const clear = invalidateConfigurationNavigation;
const off = [
onIdeEvent("fsChanged", clear),
onIdeEvent("excludedDirsChanged", clear),
onIdeEvent("docPatched", clear),
onIdeEvent("docLanguage", clear),
onIdeEvent("workspaceEditApplied", clear),
onIdeEvent("lspRefresh", refreshConfigurationNavigation),
onIdeEvent("languageServer", (e) => {
if (e.status === "starting" || e.status === "stopped" || e.status === "crashed") clear();
}),
onIdeEvent("languageServerRemoved", clear),
onServerSettled(refreshConfigurationNavigation),
onEditorEvent((e) => {
// Closing an unsaved Java buffer restores its on-disk declarations.
if (e.type === "docClosed" || e.type === "docSaved") clear();
}),
useProject.subscribe((s, previous) => {
if (s.project?.root !== previous.project?.root) clear();
}),
];
return () => {
off.forEach((dispose) => dispose());
clear();
};
}
+2
Переглянути файл
@@ -27,6 +27,7 @@ import { inlineCompletionExtension, registerInlineCompletionActions } from "./in
import { semanticExtension } from "./semantic";
import { usagesLensExtension } from "./codeLens";
import { autoImportExtension } from "./autoImport";
import { installConfigurationNavigationEvents } from "./configurationNavigation";
import { installAvailabilityEvents } from "./availability";
import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from "./actions";
import { lspTheme } from "./theme";
@@ -69,6 +70,7 @@ export function activate(): () => void {
const disposers = [
installDiagnosticsEvents(),
installAvailabilityEvents(),
installConfigurationNavigationEvents(),
registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }),
// After the run icons (order 55), as their own gutter column.
registerEditorContribution({ id: "lsp.lineMarkers", order: 56, extensions: implementationGutter }),
+54 -16
Переглянути файл
@@ -78,6 +78,15 @@ import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap";
import { isNavigationGesture, onNavigationModifier, navigationModifierHeld } from "@/platform/navigationModifier";
import { create } from "zustand";
import { tx } from "@/platform/i18n";
import {
CONFIGURATION_NAVIGATION_MAX_AGE,
configurationNavigationRevision,
navigationWord,
hasConfigurationNavigation,
invalidateConfigurationNavigation,
isConfigurationDocument,
requestNavigation,
} from "./configurationNavigation";
const highlightsBackoff = featureBackoff();
@@ -157,7 +166,7 @@ function searchingTitle(name: string | null, count: number): string {
}
function nameAt(view: EditorView, offset: number): string | null {
const word = view.state.wordAt(offset);
const word = navigationWord(view, offset);
return word ? view.state.sliceDoc(word.from, word.to) : null;
}
@@ -285,9 +294,7 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
const done = showPendingHint(view, tx`Finding navigation target…`);
const stream = kind === "implementation" ? streamedImplementations(view, offset, done) : null;
streamed = stream;
const request = stream
? () => stream.request(doc.doc, pos)
: () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind));
const request = stream ? () => stream.request(doc.doc, pos) : () => requestNavigation(view, offset, kind);
try {
locs = dedupeLocations(await gotoAfterEdits(view, request));
} finally {
@@ -481,7 +488,7 @@ function linkAt(view: EditorView, x: number, y: number): { from: number; to: num
const links = knownLinks(view);
const ref = (links && linkReference(view.state.doc, links, pos)) ?? textReference(view.state.doc, pos);
if (ref) return { from: ref.from, to: ref.to, link: true };
const word = view.state.wordAt(pos);
const word = navigationWord(view, pos);
return word && word.from < word.to ? { from: word.from, to: word.to, link: false } : null;
}
@@ -500,7 +507,7 @@ async function probeNavigation(
if (!navigable && doc) {
const pos = offsetToPos(view.state.doc, offset);
try {
navigable = (await lspRequest(() => commands.lspGoto(doc.doc, pos, "definition"))).length > 0;
navigable = (await requestNavigation(view, offset, "definition")).length > 0;
} catch (e) {
if (!isUnavailable(e)) throw e;
navigable = !!(await gotoFallback(view, doc, pos))?.length;
@@ -516,7 +523,11 @@ const linkPlugin = ViewPlugin.fromClass(
class {
last: { x: number; y: number } | null = null;
/** Probe results per word (`from:to`) for the current document. */
private probed = new Map<string, { info: NavInfo | null; plugin?: NavigationMatch } | null>();
private probed = new Map<
string,
{ result: { info: NavInfo | null; plugin?: NavigationMatch } | null; at: number }
>();
private revision = configurationNavigationRevision();
private probedDoc: Text | null = null;
private providerEpoch = -1;
private probing: string | null = null;
@@ -598,16 +609,34 @@ const linkPlugin = ViewPlugin.fromClass(
this.set({ from: target.from, to: target.to, info: null });
return;
}
if (this.probedDoc !== view.state.doc || this.providerEpoch !== navigationEpoch()) {
if (
this.probedDoc !== view.state.doc ||
this.providerEpoch !== navigationEpoch() ||
this.revision !== configurationNavigationRevision()
) {
this.providerEpoch = navigationEpoch();
this.revision = configurationNavigationRevision();
this.probeVersion++;
clearTimeout(this.probeTimer);
this.probing = null;
this.probed.clear();
this.probedDoc = view.state.doc;
}
const key = `${target.from}:${target.to}`;
if (this.probed.has(key)) {
const hit = this.probed.get(key);
const hit = this.probed.get(key);
// Configuration targets appear as the Java import progresses: they expire, and a
// remembered target must still be in the shared results. Other results last until an edit.
const config = isConfigurationDocument(view);
if (
hit &&
(!config || Date.now() - hit.at < (hit.result ? CONFIGURATION_NAVIGATION_MAX_AGE : 1000)) &&
(!hit.result || !config || hasConfigurationNavigation(view, target.from))
) {
const found = hit.result;
this.set(
hit ? { from: hit.plugin?.from ?? target.from, to: hit.plugin?.to ?? target.to, info: hit.info } : null,
found
? { from: found.plugin?.from ?? target.from, to: found.plugin?.to ?? target.to, info: found.info }
: null,
);
return;
}
@@ -626,28 +655,34 @@ const linkPlugin = ViewPlugin.fromClass(
}, 3000);
const docAt = view.state.doc;
const epochAt = navigationEpoch();
const revision = this.revision;
void probeNavigation(view, target.from)
.then((result) => {
if (
this.disposed ||
view.state.doc !== docAt ||
this.probeVersion !== version ||
epochAt !== navigationEpoch()
epochAt !== navigationEpoch() ||
revision !== configurationNavigationRevision()
)
return;
clearTimeout(this.probeTimer);
this.probed.set(key, result);
if (this.probed.size >= 128) this.probed.delete(this.probed.keys().next().value!);
this.probed.set(key, { result, at: Date.now() });
if (this.probing !== key) return;
this.probing = null;
if (!result) this.set(null);
if (result) {
this.set({ from: result.plugin?.from ?? target.from, to: result.plugin?.to ?? target.to, info: null });
if (result.plugin) return;
// Configuration navigation is a link gesture. Plain hover still
// shows schema/type documentation; do not cover the key with it here.
if (result.plugin || isConfigurationDocument(view)) return;
// Documentation must never delay the link cursor or underline.
void requestHover(view, target.from)
.then((hover) => {
if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version || !hover) return;
const info = declarationSummary(hover.contents);
this.probed.set(key, { info });
this.probed.set(key, { result: { info }, at: Date.now() });
this.set({ from: target.from, to: target.to, info });
})
.catch(() => {});
@@ -891,7 +926,10 @@ const highlightPlugin = ViewPlugin.fromClass(
export function navigationExtension(): Extension {
return [
EditorView.updateListener.of((u) => {
if (u.docChanged) editedAt.set(u.view, performance.now());
if (u.docChanged) {
editedAt.set(u.view, performance.now());
invalidateConfigurationNavigation();
}
}),
linkField,
linkPlugin,
+75
Переглянути файл
@@ -45,6 +45,7 @@ const fb = await import("./syntaxFallback");
const { gotoTargets, navigationExtension, highlightUsages } = await import("./navigation");
const { startRename, renameNamePart } = await import("./rename");
const { lspDoc } = await import("./lsp");
const { invalidateConfigurationNavigation } = await import("./configurationNavigation");
const { useLspPopup, closePopup } = await import("./popupStore");
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
@@ -96,6 +97,7 @@ function editor(doc: string, language = "typescript", anchor = 0, path = "/p/a.t
}
beforeEach(() => {
invalidateConfigurationNavigation();
for (const f of Object.values(cmd)) f.mockClear();
openFile.mockClear();
closePopup();
@@ -603,6 +605,79 @@ describe("modifier-hover navigation recovery", () => {
);
}
it.each(["yaml", "properties"])(
"%s hover underlines without a popup and clicking reuses the target",
async (language) => {
cmd.lspGoto.mockReturnValueOnce(ok([target]));
const view = editor("port: 8080", language);
move(view, 1);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("port"));
expect(cmd.lspHover).not.toHaveBeenCalled();
view.contentDOM.dispatchEvent(
new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
metaKey: mac,
ctrlKey: !mac,
}),
);
await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce());
expect(cmd.lspGoto).toHaveBeenCalledOnce();
expect(view.dom.querySelector(".cm-lsp-nav-info")).toBeNull();
},
);
it.each(["yaml", "properties"])("%s underlines the entire hyphenated configuration name", async (language) => {
cmd.lspGoto.mockReturnValueOnce(ok([target]));
const view = editor("driver-class-name: example.Driver", language);
for (const pos of [1, 6, 8, 12, 15]) {
move(view, pos);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("driver-class-name"));
}
await gotoTargets(view, "definition", 14);
expect(cmd.lspGoto).toHaveBeenCalledOnce();
expect(openFile).toHaveBeenCalledOnce();
});
it("invalidates the hover marker after dependencies change", async () => {
cmd.lspGoto.mockReturnValueOnce(ok([target]));
const view = editor("port: 8080", "yaml");
move(view, 1);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")).not.toBeNull());
invalidateConfigurationNavigation();
cmd.lspGoto.mockReturnValueOnce(ok([]));
move(view, 1);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")).toBeNull());
expect(cmd.lspGoto).toHaveBeenCalledTimes(2);
});
it("retries a stuck configuration probe without retaining the late response", async () => {
let finish!: (value: Awaited<R<unknown>>) => void;
cmd.lspGoto.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const view = editor("port: 8080", "yaml");
vi.useFakeTimers();
try {
move(view, 1);
await vi.advanceTimersByTimeAsync(3001);
cmd.lspGoto.mockReturnValueOnce(ok([target]));
move(view, 1);
await vi.advanceTimersByTimeAsync(0);
expect(cmd.lspGoto).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")).not.toBeNull());
finish({ status: "ok", data: [] });
await new Promise((resolve) => setTimeout(resolve, 20));
await gotoTargets(view, "definition", 1);
expect(cmd.lspGoto).toHaveBeenCalledTimes(2);
expect(openFile).toHaveBeenCalledOnce();
});
it("shows the link without waiting for documentation", async () => {
cmd.lspGoto.mockReturnValueOnce(ok([target]));
cmd.lspHover.mockReturnValueOnce(new Promise(() => {}));