Compare commits

...
Author SHA1 Message Date
arookieofc 3d7b0835a5 i18n: tag Maven outside-root notice with tx and add zh entries 2026-11-11 11:27:13 +08:00
arookieofc b8c63327fe fix(maven): link Windows path variants and warn on outside-root picks
The Add Maven Projects dialog hands back whatever form Windows uses:
drive-letter case may differ from the stored root and long paths come
back \?\ -prefixed, so in-root picks were silently dropped and the
button looked dead (#2626). Normalize both sides before comparing
(case-insensitively on Windows) and notify which picks were skipped
because Maven only links POMs inside the open project.
2026-11-11 10:51:13 +08:00
3 changed files with 87 additions and 9 deletions

No files matched your search

+40
View File
@@ -5,6 +5,7 @@ import { setContext } from "@/platform/context";
import type { BuildModel, GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { usePlatform } from "@/platform/platformInfo";
import { GradleView, MavenView } from "./BuildToolView";
import { activate } from "./index";
import { expandableKeys, gradleChildren, gradleRoots, gradleProjectKey } from "./model";
@@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
menu: vi.fn(),
link: vi.fn(),
notify: vi.fn(),
unlink: vi.fn(),
show: vi.fn(),
sync: vi.fn(),
@@ -52,6 +54,7 @@ vi.mock("@tauri-apps/api/core", async (original) => ({
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show: mocks.show }),
notifications: () => ({ notify: mocks.notify }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm }));
@@ -243,6 +246,43 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/
},
);
it("links `\\\\?\\`-prefixed and differently-cased picks on Windows", async () => {
const os = usePlatform.getState().os;
usePlatform.setState({ os: "windows" });
try {
useProject.setState({ project: { root: "C:\\Projects\\demo" } as ProjectInfo });
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: [], linkedProjects: [] } } },
});
mocks.open.mockResolvedValue(["\\\\?\\C:\\Projects\\demo\\svc\\pom.xml", "c:/Projects/demo/web/pom.xml"]);
await act(async () => {
await executeAction("maven.addProjects");
});
expect(mocks.set).toHaveBeenCalledWith(
"project",
"languages.java.maven.linkedProjects",
'["svc/pom.xml","web/pom.xml"]',
);
} finally {
usePlatform.setState({ os });
}
});
it("warns about picks outside the project instead of silently doing nothing", async () => {
mocks.open.mockResolvedValue("/other/pom.xml");
await act(async () => {
await executeAction("maven.addProjects");
});
expect(mocks.set).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({
level: "warning",
title: expect.stringContaining("not added"),
body: expect.stringContaining("/other/pom.xml"),
}),
);
});
it("links picked POMs that no reactor lists as independent top-level projects", async () => {
mocks.get.mockResolvedValue({
languages: { java: { maven: { unlinkedProjects: ["b/pom.xml"], linkedProjects: ["tools/pom.xml"] } } },
+45 -9
View File
@@ -26,6 +26,7 @@ import {
import { buildToolRecovery } from "@/platform/buildToolRecovery";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
import { hostOs } from "@/platform/remote";
import { editorService, notifications } from "@/workbench/contributions";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import {
@@ -294,16 +295,40 @@ export async function unlinkProjects(pom: string | null) {
await setLinked(root, [pom], false);
}
/** Project-relative `pom.xml` paths of `picked` inside `root`; others are skipped. */
function projectPoms(root: string, picked: readonly string[]): string[] {
const base = root.replace(/\\/g, "/").replace(/\/$/, "");
return picked.flatMap((p) => {
const path = p.replace(/\\/g, "/");
if (!path.startsWith(`${base}/`)) return [];
/** A picked path in one comparable form: slashes unified, `\\?\` prefixes stripped. */
function comparablePath(p: string) {
const slashes = p.replace(/\\/g, "/");
const unc = /^\/\/\?\/UNC\//i.exec(slashes);
if (unc) return `//${slashes.slice(unc[0].length)}`;
return slashes.replace(/^\/\/\?\//, "");
}
/**
* Splits `picked` into project-relative `pom.xml` paths inside `root` and the rest
* (outside the project, not a `pom.xml`, or escaping via `.`/`..`). Drive-letter
* case and `\\?\` prefixes must not matter on Windows, where the dialog may hand
* back either form (#2626).
*/
function projectPoms(root: string, picked: readonly string[]): { inside: string[]; outside: string[] } {
const base = comparablePath(root).replace(/\/$/, "");
const ignoreCase = hostOs() === "windows";
const inside: string[] = [];
const outside: string[] = [];
for (const p of picked) {
const path = comparablePath(p);
const underRoot = ignoreCase
? path.slice(0, base.length + 1).toLowerCase() === `${base.toLowerCase()}/`
: path.startsWith(`${base}/`);
if (!underRoot) {
outside.push(p);
continue;
}
const pom = path.slice(base.length + 1);
const parts = pom.split("/");
return parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml" ? [] : [pom];
});
if (parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml") outside.push(p);
else inside.push(pom);
}
return { inside, outside };
}
export async function addProjects() {
@@ -317,8 +342,19 @@ export async function addProjects() {
defaultPath: root,
filters: [{ name: "pom.xml", extensions: ["xml"] }],
});
const poms = projectPoms(root, typeof picked === "string" ? [picked] : (picked ?? []));
const paths = typeof picked === "string" ? [picked] : (picked ?? []);
if (!paths.length) return;
const { inside: poms, outside } = projectPoms(root, paths);
if (poms.length) await setLinked(root, poms, true);
// Dropping every pick silently looks exactly like the button doing nothing (#2626).
if (outside.length)
notifications().notify({
group: "tools",
level: "warning",
title: tx`Some Maven projects were not added`,
body: tx`These are outside the open project, and Maven only links POMs inside it: ${outside.slice(0, 3).join(", ")}${outside.length > 3 ? ` (+${outside.length - 3} more)` : ""}`,
key: "maven-add-outside-root",
});
}
/** Links independent Gradle builds without changing their files or build scripts. */
+2
View File
@@ -343,6 +343,8 @@
"Editor and Preview": "编辑器和预览",
"Preview Only": "仅预览",
"Add Maven Projects": "添加 Maven 项目",
"Some Maven projects were not added": "部分 Maven 项目未能添加",
"These are outside the open project, and Maven only links POMs inside it: {join}{more}": "这些项目位于已打开项目之外,而 Maven 仅链接项目内的 POM:{join}{more}",
"Unlink Maven Projects": "取消链接 Maven 项目",
"Unlink Maven projects?": "取消链接 Maven 项目?",
"Remove this project and its modules from Maven? Files will remain in the Project view.": "从 Maven 中移除此项目及其模块?文件将保留在项目视图中。",