diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index d7008cf5e..8e3cccdca 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1541,7 +1541,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option { install_bundle( data_dir, "java-usages", - "1.12.0", + "1.13.0", include_bytes!("../../../java/java-usages/openidea-java-usages.jar"), ) } diff --git a/java/java-usages/MANIFEST.MF b/java/java-usages/MANIFEST.MF index dffa21313..2418c45c4 100644 --- a/java/java-usages/MANIFEST.MF +++ b/java/java-usages/MANIFEST.MF @@ -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.12.0 +Bundle-Version: 1.13.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, diff --git a/java/java-usages/README.md b/java/java-usages/README.md index 39773681d..b074a12c3 100644 --- a/java/java-usages/README.md +++ b/java/java-usages/README.md @@ -88,7 +88,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.12.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.13.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. diff --git a/java/java-usages/openidea-java-usages.jar b/java/java-usages/openidea-java-usages.jar index abfa38710..63ca14d94 100644 Binary files a/java/java-usages/openidea-java-usages.jar and b/java/java-usages/openidea-java-usages.jar differ diff --git a/java/java-usages/src/ai/openidea/Documents.java b/java/java-usages/src/ai/openidea/Documents.java new file mode 100644 index 000000000..1662cfc52 --- /dev/null +++ b/java/java-usages/src/ai/openidea/Documents.java @@ -0,0 +1,21 @@ +package ai.openidea; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.ls.core.internal.JobHelpers; +import org.eclipse.jdt.ls.core.internal.handlers.BaseDocumentLifeCycleHandler; + +/** What a command that reads an editor's position needs before it resolves the position. */ +public final class Documents { + private Documents() {} + + /** + * Waits for the queued document changes to be reconciled, as JDT LS's own position requests + * do: `workspace/executeCommand` does not, so a position sent right after an edit would + * be resolved against the element structure from before it. + */ + public static void awaitChanges(IProgressMonitor monitor) { + JobHelpers.waitForJobs(BaseDocumentLifeCycleHandler.DOCUMENT_LIFE_CYCLE_JOBS, monitor); + if (monitor.isCanceled()) throw new OperationCanceledException(); + } +} diff --git a/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java b/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java index 3a16024d4..f4640d60b 100644 --- a/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java +++ b/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java @@ -1,5 +1,6 @@ package ai.openidea.configuration; +import ai.openidea.Documents; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; @@ -96,6 +97,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { @Override public Object executeCommand(String command, List args, IProgressMonitor monitor) throws Exception { if (command.equals("openidea.java.configuration.warmup")) return ConfigurationWarmup.request(args); + Documents.awaitChanges(monitor); 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(); diff --git a/java/java-usages/src/ai/openidea/hierarchy/HierarchyCommand.java b/java/java-usages/src/ai/openidea/hierarchy/HierarchyCommand.java index b2504dd75..b6b8bcf24 100644 --- a/java/java-usages/src/ai/openidea/hierarchy/HierarchyCommand.java +++ b/java/java-usages/src/ai/openidea/hierarchy/HierarchyCommand.java @@ -1,5 +1,6 @@ package ai.openidea.hierarchy; +import ai.openidea.Documents; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -44,6 +45,7 @@ public final class HierarchyCommand implements IDelegateCommandHandler { @Override public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + Documents.awaitChanges(monitor); String uri = (String) arguments.get(0); int line = ((Number) arguments.get(1)).intValue(); int column = ((Number) arguments.get(2)).intValue(); diff --git a/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java b/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java index 1dbedf115..821af09c2 100644 --- a/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java +++ b/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java @@ -1,5 +1,6 @@ package ai.openidea.implementations; +import ai.openidea.Documents; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -27,6 +28,7 @@ public final class ImplementationsCommand implements IDelegateCommandHandler { @Override public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + Documents.awaitChanges(monitor); List locations = new ArrayList<>(); boolean complete = find(arguments, locations, monitor); return Map.of("locations", locations, "complete", complete); diff --git a/java/java-usages/src/ai/openidea/usages/UsagesCommand.java b/java/java-usages/src/ai/openidea/usages/UsagesCommand.java index 57e61de80..b1b05bc8f 100644 --- a/java/java-usages/src/ai/openidea/usages/UsagesCommand.java +++ b/java/java-usages/src/ai/openidea/usages/UsagesCommand.java @@ -1,5 +1,6 @@ package ai.openidea.usages; +import ai.openidea.Documents; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -29,6 +30,7 @@ import org.eclipse.lsp4j.*; public final class UsagesCommand implements IDelegateCommandHandler { @Override public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + Documents.awaitChanges(monitor); String uri = (String) arguments.get(0); int line = ((Number) arguments.get(1)).intValue(); int column = ((Number) arguments.get(2)).intValue(); diff --git a/src/editor/lsp/configurationNavigation.test.ts b/src/editor/lsp/configurationNavigation.test.ts index 0e2f422cf..55b37ce87 100644 --- a/src/editor/lsp/configurationNavigation.test.ts +++ b/src/editor/lsp/configurationNavigation.test.ts @@ -200,27 +200,39 @@ describe("configuration navigation result reuse", () => { 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>) => 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.each(["settled", "refresh", "fsChanged", "excludedDirsChanged"])( + "does not swallow an in-flight click on a %s event", + async (event) => { + let resolve!: (r: Awaited>) => 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 if (event === "refresh") emitLocalIdeEvent({ type: "lspRefresh", server: 1, kind: "codeLens" }); + // The build writing its output right after an edit. + else if (event === "fsChanged") + emitLocalIdeEvent({ + type: "fsChanged", + created: ["/p/target/classes/Config.class"], + modified: [], + removed: [], + }); + else emitLocalIdeEvent({ type: "excludedDirsChanged", dirs: ["/p/target"] }); + 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(); diff --git a/src/editor/lsp/configurationNavigation.ts b/src/editor/lsp/configurationNavigation.ts index 34a6760b5..c7d795d46 100644 --- a/src/editor/lsp/configurationNavigation.ts +++ b/src/editor/lsp/configurationNavigation.ts @@ -67,9 +67,10 @@ export function navigationWord(view: EditorView, offset: number): { from: number 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. */ +/** Indexing/refresh can be progress from this very lookup, and file changes the + * build writing its output after an edit. Do not cancel an explicit click for + * them; 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; @@ -147,8 +148,8 @@ export function requestNavigation(view: EditorView, offset: number, kind: GotoKi export function installConfigurationNavigationEvents(): () => void { const clear = invalidateConfigurationNavigation; const off = [ - onIdeEvent("fsChanged", clear), - onIdeEvent("excludedDirsChanged", clear), + onIdeEvent("fsChanged", refreshConfigurationNavigation), + onIdeEvent("excludedDirsChanged", refreshConfigurationNavigation), onIdeEvent("docPatched", clear), onIdeEvent("docLanguage", clear), onIdeEvent("workspaceEditApplied", clear), diff --git a/src/features/code-insight/hierarchy.ts b/src/features/code-insight/hierarchy.ts index b01cf301a..35b19afb5 100644 --- a/src/features/code-insight/hierarchy.ts +++ b/src/features/code-insight/hierarchy.ts @@ -5,7 +5,7 @@ import { incompleteNote } from "@/editor/lsp/lsp"; import { IpcError, call, commands, nextRequestId } from "@/platform/ipc"; import { toolWindowService } from "@/workbench/contributions"; import { basename } from "@/platform/project"; -import { caretContext, hint, lspRequest, reportError, withDocument, type CaretContext } from "./ipc"; +import { caretContext, caretSynced, hint, lspRequest, reportError, withDocument, type CaretContext } from "./ipc"; import { tx } from "@/platform/i18n"; export type HierarchyMode = "call" | "type"; @@ -295,6 +295,7 @@ export async function showHierarchy(mode: HierarchyMode) { root: { name: c.word ?? basename(c.path), path: c.path, line: c.line, col: c.col }, }); toolWindowService().show("hierarchy"); + await caretSynced(c); if ((await loadRoot(true)) !== "noTarget") return; // Nothing at the caret: retry at the enclosing method / class. const root = await enclosingRoot(c, mode); diff --git a/src/features/code-insight/ipc.ts b/src/features/code-insight/ipc.ts index 96718452d..faa629e37 100644 --- a/src/features/code-insight/ipc.ts +++ b/src/features/code-insight/ipc.ts @@ -1,6 +1,6 @@ /** IPC helpers shared by the code-insight views. */ import type { EditorView } from "@codemirror/view"; -import { activeEditor, editorsFor } from "@/editor/api"; +import { activeEditor, editorsFor, whenSynced } from "@/editor/api"; import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs"; import { executeAction } from "@/platform/actions"; import { IpcError, call, commands, isCancelled, isTauri, unavailableText } from "@/platform/ipc"; @@ -71,6 +71,11 @@ export function caretContext(): CaretContext | null { }; } +/** Language servers answer from the backend's copy: the caret's position is only valid there once the editor's edits reached it. */ +export function caretSynced(c: CaretContext): Promise { + return c.view ? whenSynced(c.view) : Promise.resolve(); +} + function wordAt(view: EditorView, offset: number): string | null { const w = view.state.wordAt(offset); return w ? view.state.sliceDoc(w.from, w.to) : null; diff --git a/src/features/code-insight/usageResults.test.ts b/src/features/code-insight/usageResults.test.ts index 60d501ae8..f7c75b9ad 100644 --- a/src/features/code-insight/usageResults.test.ts +++ b/src/features/code-insight/usageResults.test.ts @@ -5,6 +5,8 @@ vi.mock("@/workbench/contributions", async (orig) => ({ ...(await orig()), toolWindowService: () => ({ show }), })); +/** The editor at the caret (`null`: a search from outside an editor). */ +let caretView: import("@codemirror/view").EditorView | null = null; const referenceResponse = vi.fn(); const lspReferences = vi.fn(async (..._args: unknown[]) => ({ status: "ok", data: null })); vi.mock("@/platform/ipc", async (orig) => { @@ -13,7 +15,7 @@ vi.mock("@/platform/ipc", async (orig) => { }); vi.mock("./ipc", async (orig) => ({ ...(await orig()), - caretContext: () => ({ doc: 1, path: "/p/a.rs", line: 0, col: 0, word: "used", view: null }), + caretContext: () => ({ doc: 1, path: "/p/a.rs", line: 0, col: 0, word: "used", view: caretView }), lspRequest: (request: () => unknown) => { request(); return referenceResponse(); @@ -109,6 +111,35 @@ describe("findUsages", () => { resetUsages(); }); + it("asks the server only once the editor's edits reached it", async () => { + const { EditorState } = await import("@codemirror/state"); + const { EditorView } = await import("@codemirror/view"); + const { documentSynced } = await import("@/editor/api"); + let release!: () => void; + const synced = new Promise((resolve) => { + release = resolve; + }); + caretView = new EditorView({ + state: EditorState.create({ doc: "used();", extensions: documentSynced.of(() => synced) }), + }); + referenceResponse.mockResolvedValueOnce({ locations: [], incomplete: null }); + lspReferences.mockClear(); + const pending = findUsages(); + try { + await new Promise((r) => setTimeout(r, 10)); + expect(lspReferences).not.toHaveBeenCalled(); + release(); + await pending; + expect(lspReferences).toHaveBeenCalledTimes(1); + } finally { + release(); + await pending; + caretView.destroy(); + caretView = null; + resetUsages(); + } + }); + it("waits for the contributed index barrier, then preserves partial and final usage events", async () => { let release!: () => void; const sync = vi.fn( diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index 3f323521b..5da12694c 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -10,6 +10,7 @@ import { shortcutLabel } from "@/platform/keymap"; import { synchronizeDocumentIndexes, toolWindowService } from "@/workbench/contributions"; import { caretContext, + caretSynced, caretPoint, hint, lspRequest, @@ -228,6 +229,7 @@ export async function fetchUsages( let classified: ClassifiedReference[] = []; let note: string | null = null; let librariesSkipped = false; + await caretSynced(c); await synchronizeDocumentIndexes(c.path); if (c.view && c.view.state.doc !== before) throw new IpcError({ kind: "cancelled", message: "", data: null }); let settled = false;