Compare commits

...
Author SHA1 Message Date
l0ng-ai f0e3b9397f fix(build): link missing tool and JDK errors to recovery
Explain missing Maven and Gradle launch failures and link sync and run notifications to their settings pages. Surface Gradle JDK import rejections from JDT LS with a JVM settings action and detect recovery before translating server messages.
2026-11-09 18:14:01 +08:00
11 changed files with 260 additions and 8 deletions

No files matched your search

+6
View File
@@ -268,6 +268,12 @@ impl Handler for ClientHandler {
"window/showMessage" => self.show_message(&mgr, &params),
"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);
+33
View File
@@ -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() {
+3 -4
View File
@@ -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 {
+2 -1
View File
@@ -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;
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, expect, it, vi } from "vitest";
const executeAction = vi.hoisted(() => vi.fn());
vi.mock("./actions", async (original) => ({
...(await original<typeof import("./actions")>()),
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: [] });
});
+37
View File
@@ -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 };
}
+7 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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 的项目。"
}
+22
View File
@@ -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,
+86
View File
@@ -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<typeof import("@/platform/ipc")>();
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<typeof import("@tauri-apps/api/core")>()),
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` })],
}),
);
});
+10 -1
View File
@@ -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,
});
},
);
}