mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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 <109515705+qjlqyf@users.noreply.github.com>
This commit is contained in:
@@ -526,6 +526,7 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ impl crate::Host {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
project.lsp.set_execution_allowed(true);
|
||||
project.lsp.prewarm_java_configuration();
|
||||
} else {
|
||||
self.environment.project_closed();
|
||||
project.agents.shutdown();
|
||||
|
||||
@@ -327,6 +327,36 @@ impl LspManager {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Start Java in trusted build-root projects during idle startup, so configuration
|
||||
/// navigation can warm after import even when no Java editor has been opened.
|
||||
pub fn prewarm_java_configuration(self: &Arc<Self>) {
|
||||
let weak = Arc::downgrade(self);
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let Some(mgr) = weak.upgrade() else { return };
|
||||
if mgr.shutting_down.load(std::sync::atomic::Ordering::Acquire)
|
||||
|| !mgr.execution_allowed.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let root = mgr.project_root.clone();
|
||||
let java_project = tokio::task::spawn_blocking(move || {
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
|
||||
.iter()
|
||||
.any(|name| root.join(name).is_file())
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if java_project {
|
||||
for server in mgr.servers_for(&LanguageId::new("java"), &mgr.project_root) {
|
||||
if server.name == SERVER {
|
||||
mgr.ensure_started(&server);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn java_import_ready(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
let mgr = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
@@ -334,6 +364,32 @@ impl LspManager {
|
||||
if let Err(e) = mgr.java_configure_compiler(&server, &run).await {
|
||||
tracing::warn!("jdtls: cannot apply compiler defaults: {e}");
|
||||
}
|
||||
// The extension only queues bounded, low-priority work; this request
|
||||
// never waits for the configuration index itself to be built.
|
||||
const WARMUP: &str = "openidea.java.configuration.warmup";
|
||||
if server.running_gen(generation).is_some()
|
||||
&& run.caps.read().commands().iter().any(|command| command == WARMUP)
|
||||
{
|
||||
let preferred: Vec<_> = mgr
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|doc| matches!(doc.language.as_str(), "yaml" | "properties"))
|
||||
.map(|doc| doc.uri.to_string())
|
||||
.collect();
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
run.peer.request::<_, Value>(
|
||||
"workspace/executeCommand",
|
||||
json!({"command": WARMUP, "arguments": ["start", preferred]}),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if !matches!(result, Ok(Ok(_))) {
|
||||
tracing::debug!("jdtls: configuration warmup unavailable; retaining on-demand navigation");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -758,7 +814,7 @@ mod tests {
|
||||
for wrapped in [false, true] {
|
||||
let mut cfg = java_config();
|
||||
let settings = json!({"java": {"project": {"referencedLibraries": ["vendor/*.jar"]}}});
|
||||
cfg.settings = Some(if wrapped { json!({"settings": settings}) } else { settings });
|
||||
cfg.settings = Some(if wrapped { json!({ "settings": settings }) } else { settings });
|
||||
configure_source_paths(&mut cfg, Some(vec!["custom".into()]));
|
||||
let settings = crate::handler::all_settings(&cfg);
|
||||
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["vendor/*.jar"]));
|
||||
|
||||
@@ -2377,7 +2377,7 @@ async fn class_file_opening_routes_sources_and_binary_documents() {
|
||||
let source = sources.join("p/Outer.java");
|
||||
std::fs::write(&source, "package p; class Outer {} ").unwrap();
|
||||
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
|
||||
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
|
||||
s.respond("workspace/executeCommand", json!({ "org.eclipse.jdt.ls.core.sourcePaths": [sources] }));
|
||||
for class in ["Outer", "Outer$Nested", "Outer$1"] {
|
||||
let path = h.root.join(format!("target/classes/p/{class}.class"));
|
||||
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
|
||||
@@ -3394,7 +3394,7 @@ async fn gopls_interactive_command_validates_before_execution_and_applies_edits(
|
||||
let reply: Value = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(s.peer().request(
|
||||
"workspace/applyEdit",
|
||||
json!({"edit": edit}),
|
||||
json!({ "edit": edit }),
|
||||
None,
|
||||
))
|
||||
})
|
||||
@@ -3679,6 +3679,54 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration.warmup"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
h.mgr.prewarm_java_configuration();
|
||||
h.mgr.prewarm_java_configuration();
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
let config = h.root.join("application.yml");
|
||||
h.mgr.did_open(DocId::next(), &config, &LanguageId::new("yaml"), 1, "app: {}\n");
|
||||
s.peer().notify("language/status", json!({"type": "ServiceReady"})).unwrap();
|
||||
eventually(|| {
|
||||
s.requested("workspace/executeCommand").iter().any(|p| p["command"] == "openidea.java.configuration.warmup")
|
||||
})
|
||||
.await;
|
||||
let requests = s.requested("workspace/executeCommand");
|
||||
let warmup = requests.iter().find(|p| p["command"] == "openidea.java.configuration.warmup").unwrap();
|
||||
assert_eq!(warmup["arguments"], json!(["start", [uri_of(&config)]]));
|
||||
assert!(s.notified("textDocument/didOpen").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_prewarm_respects_trust_shutdown_and_non_java_roots() {
|
||||
let mut fixtures = Vec::new();
|
||||
for mode in ["untrusted", "closed", "non-java"] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
if mode != "non-java" {
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
}
|
||||
if mode == "untrusted" {
|
||||
h.mgr.set_execution_allowed(false);
|
||||
}
|
||||
h.mgr.prewarm_java_configuration();
|
||||
if mode == "closed" {
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
fixtures.push(h);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2200)).await;
|
||||
for h in fixtures {
|
||||
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration command indexes the whole project, so Go to Definition (and
|
||||
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
|
||||
/// symbols in a Spring file go straight to JDT.
|
||||
|
||||
@@ -1186,7 +1186,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.5.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.6.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/** Real CodeMirror mouse/navigation path with a deliberately slow IPC boundary.
|
||||
* Semantic correctness and JVM timings live in the real-JDT configuration harness.
|
||||
*/
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { commands } from "../src/platform/ipc";
|
||||
import { usePlatform } from "../src/platform/platformInfo";
|
||||
import { provideEditorService } from "../src/workbench/contributions";
|
||||
import { lspDoc } from "../src/editor/lsp/lsp";
|
||||
import { navigationExtension } from "../src/editor/lsp/navigation";
|
||||
import { hoverExtension } from "../src/editor/lsp/hover";
|
||||
import { lspTheme } from "../src/editor/lsp/theme";
|
||||
|
||||
Object.assign(window, { __TAURI_INTERNALS__: {} });
|
||||
usePlatform.setState({ os: "macos" });
|
||||
document.body.innerHTML = `<style>
|
||||
body { --link: #215cc8; font: 16px monospace; margin: 32px; } button { margin: 12px; }
|
||||
.cm-editor { border: 1px solid #888; min-height: 160px; font: 18px monospace; }
|
||||
</style><button id="back">Back to configuration</button><button id="edit">Edit Java target</button>
|
||||
<div id="config"></div><div id="java" hidden></div>
|
||||
<output id="requests">0</output><output id="opened"></output>`;
|
||||
const configHost = document.querySelector<HTMLDivElement>("#config")!;
|
||||
const javaHost = document.querySelector<HTMLDivElement>("#java")!;
|
||||
let requests = 0;
|
||||
let hoverRequests = 0;
|
||||
let targetLine = 0;
|
||||
let clickedAt = 0;
|
||||
const latency: number[] = [];
|
||||
commands.lspGoto = async () => {
|
||||
document.querySelector("#requests")!.textContent = String(++requests);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return {
|
||||
status: "ok",
|
||||
data: [
|
||||
{
|
||||
path: "/fixture/Settings.java",
|
||||
uri: null,
|
||||
range: { start: { line: targetLine, col: 24 }, end: { line: targetLine, col: 31 } },
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
commands.lspHover = async () => {
|
||||
hoverRequests++;
|
||||
return {
|
||||
status: "ok",
|
||||
data: { contents: { kind: "plaintext", value: "dingtalk.driver-class-name String" }, range: null },
|
||||
};
|
||||
};
|
||||
commands.lspDocumentLinks = async () => ({ status: "ok", data: [] });
|
||||
commands.lspDocumentHighlights = async () => ({ status: "ok", data: [] });
|
||||
commands.cancelRequest = async () => ({ status: "ok", data: null });
|
||||
const java = new EditorView({
|
||||
parent: javaHost,
|
||||
state: EditorState.create({
|
||||
doc: "class Settings { String agentId; }",
|
||||
extensions: [
|
||||
lspDoc.of({ doc: 2, path: "/fixture/Settings.java", language: "java", uiMode: "Java", readOnly: false }),
|
||||
navigationExtension(),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const config = new EditorView({
|
||||
parent: configHost,
|
||||
state: EditorState.create({
|
||||
doc: "dingtalk:\n driver-class-name: demo\n",
|
||||
extensions: [
|
||||
EditorState.languageData.of(() => [{ wordChars: "-" }]),
|
||||
lspDoc.of({ doc: 1, path: "/fixture/application.yml", language: "yaml", uiMode: "YAML", readOnly: false }),
|
||||
navigationExtension(),
|
||||
hoverExtension(),
|
||||
lspTheme,
|
||||
],
|
||||
}),
|
||||
});
|
||||
config.contentDOM.addEventListener(
|
||||
"mousedown",
|
||||
() => {
|
||||
clickedAt = performance.now();
|
||||
},
|
||||
true,
|
||||
);
|
||||
provideEditorService({
|
||||
openFile: async (path, options) => {
|
||||
javaHost.hidden = false;
|
||||
configHost.hidden = true;
|
||||
java.dispatch({ selection: { anchor: java.state.doc.line((options?.selection?.line ?? 0) + 1).from + 24 } });
|
||||
document.querySelector("#opened")!.textContent = `${path}:${options?.selection?.line}`;
|
||||
latency.push(performance.now() - clickedAt);
|
||||
},
|
||||
} as Parameters<typeof provideEditorService>[0]);
|
||||
document.querySelector("#back")!.addEventListener("click", () => {
|
||||
javaHost.hidden = true;
|
||||
configHost.hidden = false;
|
||||
});
|
||||
document.querySelector("#edit")!.addEventListener("click", () => {
|
||||
java.dispatch({ changes: { from: 0, insert: "// moved\n" } });
|
||||
targetLine++;
|
||||
});
|
||||
Object.assign(window, { navigationMetrics: () => ({ requests, hoverRequests, latency, injectedServerDelayMs: 500 }) });
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Configuration navigation fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./configurationNavigation.fixture.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
async function keyPosition(page: Page) {
|
||||
const line = page.locator("#config .cm-line").filter({ hasText: "driver-class-name" });
|
||||
const box = (await line.boundingBox())!;
|
||||
// Skip the two-space indentation, land within the actual key.
|
||||
return { x: box.x + 45, y: box.y + box.height / 2 };
|
||||
}
|
||||
async function open(page: Page) {
|
||||
await page.addInitScript(() => Object.defineProperty(Navigator.prototype, "platform", { get: () => "MacIntel" }));
|
||||
await page.goto("/e2e/configurationNavigation.html");
|
||||
await expect(page.locator("#config .cm-content")).toContainText("driver-class-name");
|
||||
}
|
||||
async function metrics(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
(
|
||||
window as unknown as {
|
||||
navigationMetrics: () => {
|
||||
requests: number;
|
||||
hoverRequests: number;
|
||||
latency: number[];
|
||||
injectedServerDelayMs: number;
|
||||
};
|
||||
}
|
||||
).navigationMetrics(),
|
||||
);
|
||||
}
|
||||
|
||||
test("modifier hover and repeated clicks share results; editing Java invalidates them", async ({ page }, info) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await open(page);
|
||||
const key = await keyPosition(page);
|
||||
await page.keyboard.down("Meta");
|
||||
await page.mouse.move(key.x, key.y);
|
||||
await expect(page.locator(".cm-lsp-link")).toHaveText("driver-class-name");
|
||||
await expect(page.locator(".cm-lsp-link")).toHaveCSS("cursor", "pointer");
|
||||
await expect(page.locator(".cm-lsp-link")).toHaveCSS("color", "rgb(33, 92, 200)");
|
||||
expect((await metrics(page)).hoverRequests).toBe(0);
|
||||
await expect(page.locator(".cm-lsp-nav-info")).toHaveCount(0);
|
||||
await page.screenshot({ path: info.outputPath("configuration-link.png") });
|
||||
await page.mouse.click(key.x, key.y);
|
||||
await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:0");
|
||||
await page.keyboard.up("Meta");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.getByRole("button", { name: "Back to configuration" }).click();
|
||||
await page.keyboard.down("Meta");
|
||||
await page.mouse.click(key.x, key.y);
|
||||
await expect(page.locator("#java")).toBeVisible();
|
||||
await page.keyboard.up("Meta");
|
||||
}
|
||||
const warm = await metrics(page);
|
||||
expect(warm.requests).toBe(1);
|
||||
expect(warm.latency).toHaveLength(11);
|
||||
const evidence = info.outputPath("warm-navigation.json");
|
||||
await writeFile(evidence, JSON.stringify(warm, null, 2));
|
||||
await info.attach("warm-navigation.json", { path: evidence, contentType: "application/json" });
|
||||
await page.getByRole("button", { name: "Edit Java target" }).click();
|
||||
await page.getByRole("button", { name: "Back to configuration" }).click();
|
||||
await page.keyboard.down("Meta");
|
||||
await page.mouse.click(key.x, key.y);
|
||||
await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:1");
|
||||
expect((await metrics(page)).requests).toBe(2);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("clicking before the hover query completes shares the in-flight request", async ({ page }, info) => {
|
||||
await open(page);
|
||||
const key = await keyPosition(page);
|
||||
await page.keyboard.down("Meta");
|
||||
await page.mouse.move(key.x, key.y);
|
||||
await expect(page.locator("#requests")).toHaveText("1");
|
||||
await page.mouse.click(key.x, key.y);
|
||||
await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:0");
|
||||
const result = await metrics(page);
|
||||
expect(result.requests).toBe(1);
|
||||
await info.attach("inflight-navigation.json", {
|
||||
body: JSON.stringify(result, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
test("ordinary hover still shows configuration documentation", async ({ page }) => {
|
||||
await open(page);
|
||||
const key = await keyPosition(page);
|
||||
await page.mouse.move(key.x, key.y);
|
||||
await expect(page.locator(".cm-lsp-hover")).toContainText("dingtalk.driver-class-name String");
|
||||
expect((await metrics(page)).requests).toBe(0);
|
||||
expect((await metrics(page)).hoverRequests).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -52,12 +52,27 @@ modules, skips nested independent modules and build output, and honors ignore
|
||||
files for disk discovery. It does not guess a global association from matching
|
||||
text in unrelated projects.
|
||||
|
||||
Indexing is lazy. The first explicit navigation/search builds a compact snapshot;
|
||||
Trusted projects with a Maven/Gradle/Eclipse build file at the root start Java a
|
||||
moment after opening, even without a Java editor open. Once JDT reports
|
||||
ServiceReady and compiler defaults are applied, the app asks the extension to warm
|
||||
the configuration index (`openidea.java.configuration.warmup`). A low-priority
|
||||
system job waits for workspace builds, then builds the same snapshot navigation
|
||||
uses, one Java project at a time: modules with open YAML/properties files first,
|
||||
then modules with open Java editors. At most 64 modules are queued, each attempt
|
||||
has a cooperative 30-second budget, repeated readiness does not repeat finished
|
||||
work, and warming only fills free room within the retention limits below (it never
|
||||
evicts an index a lookup built). A snapshot outdated while it was being built is
|
||||
dropped. Untrusted projects do not start Java; anything warming skips keeps
|
||||
on-demand indexing.
|
||||
|
||||
Otherwise indexing is lazy. The first explicit navigation/search builds a compact snapshot;
|
||||
ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go to Definition / Ctrl+hover outside a `@Value` or property-lookup argument (checked with the bundled tree-sitter grammar) do not start indexing. At most eight project snapshots are
|
||||
retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations;
|
||||
ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST
|
||||
construction for ordinary Java units, but every accepted association is resolved
|
||||
semantically. Warm requests reuse the snapshot. Java model changes (sources on
|
||||
semantically. Builds hold a per-project lock that a waiting lookup can cancel, so a
|
||||
background build of one project never delays another, and a lookup in the same
|
||||
project reuses the result. Warm requests reuse the snapshot. Java model changes (sources on
|
||||
disk, classpath, archives) invalidate it conservatively, including dependent-type
|
||||
changes; a fingerprint of working copies with unsaved changes covers edits before
|
||||
they are saved. Opening or closing an editor, reconciling, building, and non-Java
|
||||
@@ -65,6 +80,14 @@ resource changes (configuration files are parsed by the client; metadata files a
|
||||
checked by timestamp) keep it. This is **snapshot invalidation**, not a fully
|
||||
incremental Java semantic index.
|
||||
|
||||
In YAML/properties, ⌘/Ctrl-hover underlines the whole key, hyphens and dots
|
||||
included, and doesn't open the documentation popup. Hover and Go to Definition share
|
||||
one request per key: a click after the hover reuses its answer. The editor keeps up
|
||||
to 128 answers / 4,096 locations, drops them on edits, file and project changes,
|
||||
workspace edits and language-server restarts, and expires them after 30 seconds as a
|
||||
backstop. Empty answers and errors are not kept, since the import may still be in
|
||||
progress.
|
||||
|
||||
Metadata/JAR timestamps are checked before reuse. The client caches parsed disk
|
||||
configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and
|
||||
performs bounded discovery/parsing on a blocking worker. It does not continuously
|
||||
@@ -74,7 +97,7 @@ Bounds: 20,000 Java units, 100,000 associations per snapshot, 2 MiB per candidat
|
||||
metadata/configuration file (Java source: 2M UTF-16 units), 1,024-character property names, 2,000 configuration files/results, 50,000 filesystem
|
||||
entries per root, and depth limits on recursive bindings/YAML. Java requests and
|
||||
references are cancellable. File/association scan limits and unreadable files carry the
|
||||
existing incomplete-result indicator; unsupported syntax keys are omitted. First navigation can take seconds on a
|
||||
existing incomplete-result indicator; unsupported syntax keys are omitted. Navigation before warming finishes can take seconds on a
|
||||
cold project; classpath import and source download remain owned by JDT LS.
|
||||
|
||||
## Deliberate limits
|
||||
@@ -105,7 +128,8 @@ cargo test -p ide-language -p ide-lsp
|
||||
The isolated real-server harness covers custom bindings, inheritance, nesting,
|
||||
collections/maps, records, constructor aliases, factory methods, consumers,
|
||||
constant keys, negative identity matches, dependency metadata/source locations,
|
||||
reverse queries, and unsaved-change/close invalidation. Optional stress fixtures
|
||||
reverse queries, warming (queued once, reused by the first lookup, cancellable), and
|
||||
unsaved-change/close invalidation. Optional stress fixtures
|
||||
add 3,000 ordinary units and 100 binding classes; output includes cold/warm/edit
|
||||
latencies for the JDT command only, excluding server import, UI and configuration-file discovery. The optional actual Spring Boot JAR checks `server.port`,
|
||||
`spring.datasource.url` and `spring.jackson.date-format` through dependency
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.5.0
|
||||
Bundle-Version: 1.6.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.5.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.6.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -83,6 +83,10 @@ Version 1.5.0 answers `{locations, complete}`: `complete` when the type is decla
|
||||
dependencies' implementations after the first ones. The search also covers the project's Kotlin
|
||||
classes attached by the Kotlin bridge; its reproducible SHA-256 is
|
||||
`7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`.
|
||||
Version 1.6.0 adds `openidea.java.configuration.warmup`, which builds configuration indexes in a
|
||||
low-priority background job after import, and builds them under cancellable per-project locks
|
||||
instead of one global lock; its reproducible SHA-256 is
|
||||
`33cfa1bb8489247ffeb6fc8249966c8a2955b7b2858c8169a70486eb70ade696`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
|
||||
Binary file not shown.
@@ -6,6 +6,7 @@
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
|
||||
<command id="openidea.java.configuration"/>
|
||||
<command id="openidea.java.configuration.warmup"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
|
||||
<command id="openidea.java.implementations"/>
|
||||
|
||||
@@ -4,7 +4,10 @@ import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.zip.ZipFile;
|
||||
import com.google.gson.*;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
@@ -29,6 +32,8 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024;
|
||||
private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong();
|
||||
private static final Map<String, Index> CACHE = new LinkedHashMap<>();
|
||||
/** One build per project at a time; waiting lookups stay cancellable. */
|
||||
private static final Map<String, ReentrantLock> LOCKS = new ConcurrentHashMap<>();
|
||||
/** Unsaved edits are covered by the working-copy digest, so reconciles never
|
||||
* invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and
|
||||
* non-Java resources, reported as F_CONTENT on a project, root or package
|
||||
@@ -56,6 +61,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
|
||||
if (command.equals("openidea.java.configuration.warmup")) return ConfigurationWarmup.request(args);
|
||||
String uri = (String) args.get(0);
|
||||
String key = args.get(1) instanceof String s ? s : null;
|
||||
int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue();
|
||||
@@ -138,7 +144,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
(line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter());
|
||||
}
|
||||
private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); }
|
||||
private static IJavaProject project(String uri) {
|
||||
static IJavaProject project(String uri) {
|
||||
Path path;
|
||||
try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; }
|
||||
IJavaProject best = null;
|
||||
@@ -163,7 +169,33 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception {
|
||||
/** Builds (or reuses) the project's index in the background; its generation. */
|
||||
static long warm(IJavaProject project, IProgressMonitor monitor) throws Exception {
|
||||
return index(project, monitor, true).generation;
|
||||
}
|
||||
|
||||
/** Warming fills free cache room only; it never evicts an index a lookup built. */
|
||||
static boolean hasWarmupCapacity(IJavaProject project) {
|
||||
synchronized (CACHE) {
|
||||
return CACHE.containsKey(project.getHandleIdentifier()) || (CACHE.size() < 8 &&
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() < MAX_ENTRIES);
|
||||
}
|
||||
}
|
||||
|
||||
private static Index index(IJavaProject project, IProgressMonitor monitor) throws Exception {
|
||||
return index(project, monitor, false);
|
||||
}
|
||||
|
||||
private static Index index(IJavaProject project, IProgressMonitor monitor, boolean background) throws Exception {
|
||||
ReentrantLock lock = LOCKS.computeIfAbsent(project.getHandleIdentifier(), ignored -> new ReentrantLock());
|
||||
while (!lock.tryLock(50, TimeUnit.MILLISECONDS)) check(monitor);
|
||||
try {
|
||||
check(monitor);
|
||||
return build(project, monitor, background);
|
||||
} finally { lock.unlock(); }
|
||||
}
|
||||
|
||||
private static Index build(IJavaProject project, IProgressMonitor monitor, boolean background) throws Exception {
|
||||
long revision = REVISION.get();
|
||||
String id = project.getHandleIdentifier();
|
||||
Map<String, ICompilationUnit> copies = new TreeMap<>();
|
||||
@@ -181,7 +213,8 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
String workingCopies = HexFormat.of().formatHex(digest.digest());
|
||||
Index cached = CACHE.get(id);
|
||||
Index cached;
|
||||
synchronized (CACHE) { cached = CACHE.get(id); }
|
||||
if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) {
|
||||
boolean valid = true;
|
||||
for (var entry : cached.files.entrySet()) {
|
||||
@@ -227,11 +260,19 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
roots.add(project.getProject().getLocationURI().toString());
|
||||
builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project);
|
||||
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed, GENERATION.incrementAndGet());
|
||||
CACHE.remove(id);
|
||||
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
|
||||
CACHE.remove(CACHE.keySet().iterator().next());
|
||||
CACHE.put(id, result);
|
||||
check(monitor);
|
||||
// A warmed index that the model has already outdated is dropped, not published.
|
||||
if (background && revision != REVISION.get()) throw new OperationCanceledException();
|
||||
synchronized (CACHE) {
|
||||
if (background && ((!CACHE.containsKey(id) && CACHE.size() >= 8) ||
|
||||
CACHE.entrySet().stream().filter(e -> !e.getKey().equals(id)).mapToInt(e -> e.getValue().entries.size()).sum()
|
||||
+ result.entries.size() > MAX_ENTRIES)) throw new OperationCanceledException();
|
||||
CACHE.remove(id);
|
||||
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
|
||||
CACHE.remove(CACHE.keySet().iterator().next());
|
||||
CACHE.put(id, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package ai.openidea.configuration;
|
||||
|
||||
import java.util.*;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.*;
|
||||
import org.eclipse.core.runtime.jobs.Job;
|
||||
import org.eclipse.jdt.core.*;
|
||||
|
||||
/** One bounded, cooperative queue per language-server process. No polling timer,
|
||||
* retained ASTs, or project builds; an ordinary lookup remains the fallback. */
|
||||
final class ConfigurationWarmup {
|
||||
private static final Map<String, IJavaProject> pending = new LinkedHashMap<>();
|
||||
private static final Set<String> attempted = new HashSet<>();
|
||||
private static final Map<String, Long> completed = new LinkedHashMap<>();
|
||||
private static boolean scheduled, stopped;
|
||||
private static String active;
|
||||
private static final Job job = new Job("Preparing configuration navigation") {
|
||||
@Override protected IStatus run(IProgressMonitor monitor) {
|
||||
// Compiler defaults/import can schedule a build after ServiceReady.
|
||||
// Yield to it instead of blocking a worker on the build job family.
|
||||
if (building()) {
|
||||
synchronized (ConfigurationWarmup.class) {
|
||||
if (!stopped) schedule(2000);
|
||||
else scheduled = false;
|
||||
}
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
IJavaProject project;
|
||||
synchronized (ConfigurationWarmup.class) {
|
||||
if (stopped || pending.isEmpty()) { scheduled = false; return Status.OK_STATUS; }
|
||||
String id = pending.keySet().iterator().next();
|
||||
project = pending.remove(id);
|
||||
active = id;
|
||||
}
|
||||
long deadline = System.nanoTime() + 30_000_000_000L;
|
||||
IProgressMonitor bounded = new ProgressMonitorWrapper(monitor) {
|
||||
@Override public boolean isCanceled() {
|
||||
return super.isCanceled() || System.nanoTime() >= deadline ||
|
||||
!project.exists() || !project.getProject().isOpen();
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (ConfigurationCommand.hasWarmupCapacity(project)) {
|
||||
long build = ConfigurationCommand.warm(project, bounded);
|
||||
synchronized (ConfigurationWarmup.class) {
|
||||
if (completed.size() < 8) completed.put(project.getHandleIdentifier(), build);
|
||||
}
|
||||
}
|
||||
} catch (OperationCanceledException e) {
|
||||
// No partial index is published. A user lookup can retry later.
|
||||
} catch (Exception e) {
|
||||
org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin.logException("Configuration warmup skipped", e);
|
||||
} finally {
|
||||
synchronized (ConfigurationWarmup.class) {
|
||||
active = null;
|
||||
if (!stopped && !pending.isEmpty()) schedule(200);
|
||||
else scheduled = false;
|
||||
}
|
||||
}
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
};
|
||||
static { job.setSystem(true); job.setPriority(Job.DECORATE); }
|
||||
|
||||
private static boolean building() {
|
||||
for (Object family : new Object[] { ResourcesPlugin.FAMILY_AUTO_BUILD, ResourcesPlugin.FAMILY_MANUAL_BUILD })
|
||||
for (Job other : Job.getJobManager().find(family))
|
||||
if (other.getState() != Job.NONE) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static synchronized Object request(List<Object> args) throws JavaModelException {
|
||||
String action = args.isEmpty() ? "start" : String.valueOf(args.get(0));
|
||||
if (action.equals("cancel")) {
|
||||
stopped = true;
|
||||
pending.clear();
|
||||
job.cancel();
|
||||
if (active == null) scheduled = false;
|
||||
} else if (action.equals("start") && !stopped) {
|
||||
// Open Java buffers identify the user's active modules. Keep those
|
||||
// ahead of the rest when the workspace exceeds the cache budget.
|
||||
Set<String> open = new HashSet<>();
|
||||
for (ICompilationUnit unit : JavaCore.getWorkingCopies(null)) open.add(unit.getJavaProject().getHandleIdentifier());
|
||||
for (ICompilationUnit unit : JavaCore.getWorkingCopies(
|
||||
org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()))
|
||||
open.add(unit.getJavaProject().getHandleIdentifier());
|
||||
Set<String> preferred = new HashSet<>();
|
||||
if (args.size() > 1 && args.get(1) instanceof List<?> uris) {
|
||||
for (Object uri : uris) if (uri instanceof String text) {
|
||||
IJavaProject project = ConfigurationCommand.project(text);
|
||||
if (project != null) preferred.add(project.getHandleIdentifier());
|
||||
}
|
||||
}
|
||||
List<IJavaProject> projects = new ArrayList<>(Arrays.asList(
|
||||
JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()));
|
||||
projects.sort(Comparator.comparing((IJavaProject p) -> !preferred.contains(p.getHandleIdentifier()))
|
||||
.thenComparing(p -> !open.contains(p.getHandleIdentifier()))
|
||||
.thenComparing(IJavaProject::getElementName));
|
||||
for (IJavaProject project : projects) {
|
||||
String id = project.getHandleIdentifier();
|
||||
if (project.getProject().isOpen() && !attempted.contains(id) && pending.size() < 64) {
|
||||
attempted.add(id);
|
||||
pending.put(id, project);
|
||||
}
|
||||
}
|
||||
if (!scheduled && !pending.isEmpty()) { scheduled = true; job.schedule(2000); }
|
||||
}
|
||||
return Map.of("scheduled", scheduled, "pending", pending.size(), "active", active == null ? "" : active,
|
||||
"completed", new LinkedHashMap<>(completed));
|
||||
}
|
||||
}
|
||||
@@ -159,11 +159,28 @@ try:
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}})
|
||||
request('java/buildWorkspace', True)
|
||||
assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init
|
||||
def warmup(action='start'):
|
||||
return request('workspace/executeCommand', {'command': 'openidea.java.configuration.warmup', 'arguments': [action, [(project / 'application.yml').as_uri()]]})
|
||||
# Queuing is cheap and idempotent; the index is built by a background job.
|
||||
queued_at = time.perf_counter()
|
||||
queued = warmup()
|
||||
assert queued['scheduled'] and queued['pending'] > 0 and not queued['completed'], queued
|
||||
assert warmup()['pending'] == queued['pending'], 'a repeated start queued the project twice'
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
warmed = warmup('status')
|
||||
if not warmed['scheduled']: break
|
||||
time.sleep(.1)
|
||||
assert not warmed['scheduled'] and warmed['completed'], warmed
|
||||
assert warmup()['pending'] == 0, 'a repeated start queued a warmed project again'
|
||||
prewarm_seconds = time.perf_counter() - queued_at
|
||||
results = {}
|
||||
for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']:
|
||||
start = time.perf_counter()
|
||||
result = query(key)
|
||||
results[key] = {'seconds': time.perf_counter() - start, 'response': result}
|
||||
if key == 'app':
|
||||
assert result['generation'] in warmed['completed'].values(), ('the first lookup rebuilt the warmed index', result, warmed)
|
||||
assert result['locations'], (key, result)
|
||||
attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']})
|
||||
assert attached and 'LibrarySettings' in attached and 'port' in attached, attached
|
||||
@@ -238,7 +255,9 @@ try:
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}})
|
||||
restored = query('app.nested.url')
|
||||
assert restored['locations'], restored
|
||||
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
|
||||
cancelled = warmup('cancel')
|
||||
assert not cancelled['scheduled'] and cancelled['pending'] == 0, cancelled
|
||||
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'prewarmSeconds': prewarm_seconds, 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
|
||||
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
|
||||
print(json.dumps(evidence, indent=2))
|
||||
finally:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { KnipConfig } from "knip";
|
||||
|
||||
const config: KnipConfig = {
|
||||
// Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins.
|
||||
entry: ["e2e/pythonHighlight.fixture.ts"],
|
||||
// Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins;
|
||||
// standalone browser fixtures are loaded by their own HTML pages.
|
||||
entry: ["e2e/*.fixture.{ts,tsx}"],
|
||||
project: [
|
||||
"src/**/*.{ts,tsx,css}",
|
||||
"!src/editor/core/cm/angular/parser.terms.ts",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {}));
|
||||
|
||||
Reference in New Issue
Block a user