diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index e54a264b4..c983bbb29 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -268,6 +268,12 @@ impl Handler for ClientHandler { "window/showMessage" => self.show_message(&mgr, ¶ms), "window/logMessage" => { let text = params.get("message").and_then(Value::as_str).unwrap_or_default(); + if self.server.name == "jdtls" + && text.contains("Can't use Java ") + && text.contains(" to import Gradle project ") + { + self.show_message(&mgr, &json!({ "type": 1, "message": text.lines().next().unwrap_or(text) })); + } match params.get("type").and_then(Value::as_u64) { Some(1) if self.server.name == "jdtls" && text.contains(crate::jdtls::reserved_names::IMPORT_FAILURE) => { tracing::warn!("[{}] {text}", self.server.name); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index ad30aac8f..a260ed656 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -5606,6 +5606,39 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() { let _ = std::fs::remove_file(&nul); } +#[tokio::test(flavor = "multi_thread")] +async fn gradle_incompatible_jdk_log_is_surfaced_once_across_message_channels() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let s = h.launcher.state("jdtls"); + h.open("A.java", "class A {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let failure = "Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle."; + for _ in 0..2 { + s.peer() + .notify( + "window/logMessage", + json!({ "type": 1, "message": format!("{failure}\njava.lang.Exception: details stay in the log") }), + ) + .unwrap(); + } + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == failure))) + .await; + s.peer().notify("window/showMessage", json!({ "type": 1, "message": failure })).unwrap(); + s.peer().notify("window/logMessage", json!({ "type": 1, "message": "unrelated internal error" })).unwrap(); + s.peer().notify("window/showMessage", json!({ "type": 2, "message": "after" })).unwrap(); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == "after"))) + .await; + let events = h.events.lock(); + let messages: Vec<_> = events + .iter() + .filter_map(|e| match e { + IdeEvent::Notify { body: Some(body), .. } => Some(body.as_str()), + _ => None, + }) + .collect(); + assert_eq!(messages, vec![failure, "after"]); +} + /// Pull Members Up / Push Members Down: the extension's answers, refusals and edits. #[tokio::test(flavor = "multi_thread")] async fn java_hierarchy_refactorings_use_the_extension_commands() { diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 0e1945add..b7a08e855 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -23,7 +23,7 @@ import { type RunConfig, type SyncProgress, } from "@/platform/ipc"; -import { downloadJdkActions } from "@/platform/jdk"; +import { buildToolRecovery } from "@/platform/buildToolRecovery"; import { useBuildModel } from "@/platform/buildModel"; import { useProject } from "@/platform/project"; import { editorService, notifications } from "@/workbench/contributions"; @@ -454,9 +454,8 @@ export async function syncTool(tool: BuildTool, buildDir: string | null = null): group: "run", level: "warning", title: tool === "maven" ? "Maven sync finished with errors" : "Gradle sync failed", - body: error, + ...buildToolRecovery(error), key: `build-sync-${tool}`, - actions: downloadJdkActions(error), }); } catch (e) { if (!isCancelled(e)) @@ -464,7 +463,7 @@ export async function syncTool(tool: BuildTool, buildDir: string | null = null): group: "run", level: "error", title: tool === "maven" ? "Cannot reload Maven projects" : "Cannot reload Gradle projects", - body: e instanceof Error ? e.message : String(e), + ...buildToolRecovery(e instanceof Error ? e.message : String(e)), key: `build-sync-${tool}`, }); } finally { diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 1398aff90..0c67b9cb3 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -27,6 +27,7 @@ import { prepareJavaLaunch, } from "@/platform/javaLaunch"; import { downloadJdkActions } from "@/platform/jdk"; +import { buildToolRecovery } from "@/platform/buildToolRecovery"; import { useProject } from "@/platform/project"; import { executeAction } from "@/platform/actions"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -930,7 +931,7 @@ export async function startRun( group: "run", level: "error", title: `Error running '${remoteLabel(config)}'`, - body: message, + ...buildToolRecovery(message), }); } return getSession(sessionId) ?? null; diff --git a/src/platform/buildToolRecovery.test.ts b/src/platform/buildToolRecovery.test.ts new file mode 100644 index 000000000..ca2bd8f57 --- /dev/null +++ b/src/platform/buildToolRecovery.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, it, vi } from "vitest"; + +const executeAction = vi.hoisted(() => vi.fn()); +vi.mock("./actions", async (original) => ({ + ...(await original()), + executeAction, +})); +import { buildToolRecovery } from "./buildToolRecovery"; + +afterEach(() => vi.clearAllMocks()); + +it.each([ + ["cannot start mvn: No such file or directory (os error 2)", "maven"], + ["command not found: mvn", "maven"], + ["command not found: /opt/apache maven/bin/mvn", "maven"], + ["command not found: C:\\Maven\\bin\\mvn.cmd", "maven"], + ["cannot start gradle: No such file or directory (os error 2)", "gradle"], + ["command not found: gradle", "gradle"], + ["command not found: C:\\Gradle\\bin\\gradle.bat", "gradle"], + ["command not found: ./gradlew", "gradle"], +])("links missing build tool to its settings: %s", (message, tool) => { + const result = buildToolRecovery(message); + expect(result.body).toContain(`${tool === "maven" ? "Maven" : "Gradle"} could not be started`); + expect(result.body).toContain(message); + expect(result.actions).toHaveLength(1); + result.actions[0]!.run(); + expect(executeAction).toHaveBeenCalledWith("file.settings", `build.${tool}`); +}); + +it("links the reported Gradle import rejection to the Gradle JVM picker and its JDK download", () => { + const message = + "Nov 9, 2026, 3:49:11 PM Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle."; + const result = buildToolRecovery(message); + expect(result.body).toContain("Download JDK"); + expect(result.actions[0]!.label).toBe("Choose Gradle JVM"); + result.actions[0]!.run(); + expect(executeAction).toHaveBeenCalledWith("file.settings", "build.gradle"); +}); + +it.each([ + "command not found: npm", + "Maven tests failed", + "Gradle build failed", + "cannot start mvn: Permission denied (os error 13)", +])("leaves unrelated failures alone: %s", (message) => { + expect(buildToolRecovery(message)).toEqual({ body: message, actions: [] }); +}); diff --git a/src/platform/buildToolRecovery.ts b/src/platform/buildToolRecovery.ts new file mode 100644 index 000000000..6bb6d5bc9 --- /dev/null +++ b/src/platform/buildToolRecovery.ts @@ -0,0 +1,37 @@ +import { executeAction } from "./actions"; +import { tx } from "./i18n"; +import { downloadJdkActions } from "./jdk"; + +/** Recovery shared by build sync, run configurations and language-server import errors. */ +export function buildToolRecovery(message: string) { + const actions = downloadJdkActions(message); + const incompatible = /Can't use Java .* and Gradle .* to import Gradle project/i.test(message); + const missing = /command not found: ([^\r\n]+)|cannot start ([^\r\n]+?): [^\r\n]*\(os error [23]\)/i.exec(message); + const command = (missing?.[1] ?? missing?.[2] ?? "").trim().replace(/^["'`]|["'`]$/g, ""); + const namedTool = /(?:^|[/\\])(?:mvn|mvnw|mvnd)(?:\.(?:cmd|bat|exe))?$/i.test(command) + ? "maven" + : /(?:^|[/\\])gradlew?(?:\.(?:bat|exe))?$/i.test(command) + ? "gradle" + : undefined; + const recoveryTool = incompatible ? "gradle" : namedTool; + if (!recoveryTool) return { body: message, actions }; + const label = + recoveryTool === "maven" + ? tx("Open Maven Settings") + : incompatible + ? tx("Choose Gradle JVM") + : tx("Open Gradle Settings"); + actions.push({ label, run: () => void executeAction("file.settings", `build.${recoveryTool}`) }); + const hint = incompatible + ? tx( + "Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.", + ) + : recoveryTool === "maven" + ? tx( + "Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.", + ) + : tx( + "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.", + ); + return { body: `${hint}\n\n${message}`, actions }; +} diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 2fcc3b9aa..974c288a0 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -735,5 +735,11 @@ "Select a .NET project": "Select a .NET project", "Project evaluation failed": "Project evaluation failed", "dotnet exited with code {0}": "dotnet exited with code {0}", - "Microsoft.Testing.Platform was not found in the restored project.": "Microsoft.Testing.Platform was not found in the restored project." + "Microsoft.Testing.Platform was not found in the restored project.": "Microsoft.Testing.Platform was not found in the restored project.", + "Open Maven Settings": "Open Maven Settings", + "Open Gradle Settings": "Open Gradle Settings", + "Choose Gradle JVM": "Choose Gradle JVM", + "Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.": "Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.", + "Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.": "Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.", + "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 7fe741fb0..af4e5ab51 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7069,5 +7069,11 @@ "Run test": "运行测试", "Project evaluation failed": "项目求值失败", "dotnet exited with code {0}": "dotnet 已退出,退出码 {0}", - "Microsoft.Testing.Platform was not found in the restored project.": "还原后的项目中未找到 Microsoft.Testing.Platform。" + "Microsoft.Testing.Platform was not found in the restored project.": "还原后的项目中未找到 Microsoft.Testing.Platform。", + "Open Maven Settings": "打开 Maven 设置", + "Open Gradle Settings": "打开 Gradle 设置", + "Choose Gradle JVM": "选择 Gradle JVM", + "Choose a JDK compatible with this Gradle version in Gradle JVM. Download JDK is available in the JVM selector.": "请在 Gradle JVM 中选择与此 Gradle 版本兼容的 JDK。JVM 选择器中提供下载 JDK 选项。", + "Maven could not be started. Configure Maven home in Maven settings, install Maven on PATH, or use a project with a Maven wrapper.": "无法启动 Maven。请在 Maven 设置中配置 Maven 主目录,将 Maven 安装到 PATH,或使用带有 Maven wrapper 的项目。", + "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "无法启动 Gradle。请在 Gradle 设置中配置本地安装,将 Gradle 安装到 PATH,或使用带有 Gradle wrapper 的项目。" } diff --git a/src/workbench/Workbench.test.tsx b/src/workbench/Workbench.test.tsx index f7bd710e9..ee23a4dc6 100644 --- a/src/workbench/Workbench.test.tsx +++ b/src/workbench/Workbench.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { emitLocalIdeEvent } from "@/platform/ipc"; +import { setLocale } from "@/platform/i18n"; import { registerStatusItem, registerToolWindow, type StatusItemContribution } from "./contributions"; import { orderStatusItems, StatusBar, TaskStatus } from "./StatusBar"; import { orderTitleItems } from "./TitleBar"; @@ -200,6 +201,27 @@ describe("notifications", () => { uninstall(); }); + it.each(["en", "zh"])("adds Gradle JVM recovery before translating backend messages (%s)", async (locale) => { + await setLocale(locale); + const uninstall = installNotifyEvents(); + try { + act(() => + emitLocalIdeEvent({ + type: "notify", + level: "error", + title: "jdtls", + body: "Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle.", + }), + ); + const item = useNotifications.getState().items[0]!; + expect(item.actions?.map((a) => a.label)).toEqual([locale === "zh" ? "选择 Gradle JVM" : "Choose Gradle JVM"]); + expect(item.body).toContain(locale === "zh" ? "下载 JDK" : "Download JDK"); + } finally { + uninstall(); + await setLocale("en"); + } + }); + it("the Notifications tool window lists history, marks it read and hides balloons", () => { const dispose = registerToolWindow({ id: NOTIFICATIONS_TOOL_WINDOW, diff --git a/src/workbench/buildToolRecovery.test.ts b/src/workbench/buildToolRecovery.test.ts new file mode 100644 index 000000000..3e43d9628 --- /dev/null +++ b/src/workbench/buildToolRecovery.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { BuildModel, ProjectInfo, RunConfig } from "@/platform/ipc"; + +const mocks = vi.hoisted(() => ({ sync: vi.fn(), start: vi.fn(), notify: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + buildSync: mocks.sync, + runStart: mocks.start, + runConfigs: async () => ({ status: "ok", data: [] }), + }, + }; +}); +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage: unknown = null; + }, +})); + +import { useProject } from "@/platform/project"; +import { provideNotificationService } from "@/workbench/contributions"; +import { resetBuild, syncTool, useBuild } from "../features/build-tools/store"; +import { resetRuns, startRun } from "../features/run/store"; + +beforeEach(() => { + vi.clearAllMocks(); + useProject.setState({ project: { root: "/p", trusted: true } as ProjectInfo }); + useBuild.setState({ root: "/p", syncing: {}, model: { maven: null, gradle: [] } }); + provideNotificationService({ notify: mocks.notify }); +}); +afterEach(() => { + resetBuild(); + resetRuns(); +}); + +it.each(["maven", "gradle"] as const)("offers settings for %s sync failures, returned or thrown", async (tool) => { + const command = tool === "maven" ? "mvn" : "gradle"; + const message = `cannot start ${command}: No such file or directory (os error 2)`; + mocks.sync.mockRejectedValueOnce(new Error(message)); + await syncTool(tool); + expect(mocks.notify).toHaveBeenLastCalledWith( + expect.objectContaining({ + actions: [expect.objectContaining({ label: `Open ${tool === "maven" ? "Maven" : "Gradle"} Settings` })], + body: expect.stringContaining(message), + }), + ); + const model = (tool === "maven" + ? { maven: { syncError: message }, gradle: [] } + : { maven: null, gradle: [{ syncError: message }] }) as unknown as BuildModel; + mocks.sync.mockResolvedValueOnce({ status: "ok", data: model }); + await syncTool(tool); + expect(mocks.notify).toHaveBeenLastCalledWith( + expect.objectContaining({ + actions: [expect.objectContaining({ label: `Open ${tool === "maven" ? "Maven" : "Gradle"} Settings` })], + }), + ); +}); + +it.each(["maven", "gradle"] as const)("offers settings when a %s run cannot start", async (kind) => { + const command = kind === "maven" ? "mvn" : "gradle"; + const config: RunConfig = { + id: kind, + name: `${command} test`, + kind, + command, + args: ["test"], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + }; + mocks.start.mockRejectedValueOnce(new Error(`command not found: ${command}`)); + await startRun(config, undefined, undefined, { prepared: true, show: false }); + expect(mocks.start).toHaveBeenCalledOnce(); + expect(mocks.notify).toHaveBeenLastCalledWith( + expect.objectContaining({ + actions: [expect.objectContaining({ label: `Open ${kind === "maven" ? "Maven" : "Gradle"} Settings` })], + }), + ); +}); diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index bb51162e3..85b35d02d 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { create } from "zustand"; import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { buildToolRecovery } from "@/platform/buildToolRecovery"; import { txTemplate } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import type { NotificationService } from "./contributions"; @@ -202,6 +203,14 @@ export function installNotifyEvents(): () => void { return onIdeEvent( "notify", // Backend text may carry values (paths, names); `{name}` catalog templates translate it. - (e) => void notify({ level: e.level, title: txTemplate(e.title), body: e.body ? txTemplate(e.body) : undefined }), + (e) => { + const recovery = e.body ? buildToolRecovery(e.body) : undefined; + void notify({ + level: e.level, + title: txTemplate(e.title), + ...recovery, + body: recovery ? txTemplate(recovery.body) : undefined, + }); + }, ); }