feat(maven): unlink and re-add Maven projects (#297)

As in IntelliJ IDEA, a root Maven project can be unlinked from the Maven tool window's context menu (Unlink Maven Projects, with confirmation) and linked again with the toolbar's Add Maven Projects (pick a pom.xml). Unlinked root POMs are stored in the project's Maven settings (unlinkedProjects). ide-host computes the unlinked roots and their modules once with ide-build and applies the same exclusion to the build model, Maven sync, project source roots, detected run configurations and the Java language server import (java.import.exclusions). The project's files stay visible in the Project view as plain files.

Refs #271
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 08:12:49 +08:00
1 джерело c55fd9b86e
коміт 2ea47873cf
21 files changed
+563 -19

No files matched your search

+2
Переглянути файл
@@ -4075,6 +4075,8 @@ export type MavenProject = {
/** Build Tools › Maven. */
export type MavenSettings = {
/** Project-relative root POMs removed from the Maven project model. */
unlinkedProjects?: string[],
/** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */
home?: string,
/** User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. */
+3
Переглянути файл
@@ -131,6 +131,7 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
) : (
<IconButton icon="refresh" size="small" iconSize={16} action={`${tool}.reload`} />
)}
{maven && <IconButton icon="plus" size="small" iconSize={16} action="maven.addProjects" />}
<span className={styles.sep} />
<IconButton
icon="run"
@@ -302,6 +303,8 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
else items.push({ label: "Create Run Configuration…", icon: "edit", run: () => void createRunConfig(config) });
}
if (node.kind === "mavenProject") {
if (!node.project.aggregated)
items.push({ kind: "action", action: "maven.unlinkProjects", arg: { pom: node.project.pom } });
items.push(
{
label: "Open pom.xml",
+1
Переглянути файл
@@ -74,6 +74,7 @@ describe("build tools feature", () => {
.map((a) => a.id)
.sort(),
).toEqual([
"maven.addProjects",
"maven.analyzeDependencies",
"maven.executeGoal",
"maven.reload",
+19 -1
Переглянути файл
@@ -21,6 +21,8 @@ import { buildToolsRunAnything } from "./runAnything";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model";
import {
ANALYZER_KIND,
addProjects,
unlinkProjects,
cancelSync,
loadModel,
openAnalyzer,
@@ -110,7 +112,7 @@ function apply(model: BuildModel | null, registered: Partial<Record<BuildTool, (
for (const tool of ["maven", "gradle"] as const) {
const info = tool === "maven" ? model?.maven : model?.gradle;
const key = `${tool}:${root}`;
if (info && info.syncedAt == null && !synced.has(key)) {
if (info && (tool !== "maven" || model?.maven?.projects.length) && info.syncedAt == null && !synced.has(key)) {
synced.add(key);
void syncTool(tool);
}
@@ -165,6 +167,22 @@ export function activate(): () => void {
if (e.key === "languages" || e.key.startsWith("languages.java")) scheduleLoad();
}),
registerActions([
{
id: "maven.addProjects",
title: "Add Maven Projects",
category: "Maven",
icon: "plus",
when: "projectOpen",
run: () => addProjects(),
},
{
id: "maven.unlinkProjects",
title: "Unlink Maven Projects",
category: "Maven",
hidden: true,
when: MAVEN_WHEN,
run: ({ arg }) => unlinkProjects(analyzerPom(arg)),
},
{
id: "maven.reload",
title: "Reload All Maven Projects",
+137
Переглянути файл
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, ProjectInfo } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { MavenView } from "./BuildToolView";
import { activate } from "./index";
import { useBuild } from "./store";
const mocks = vi.hoisted(() => ({
get: vi.fn(),
set: vi.fn(),
model: vi.fn(),
configs: vi.fn(),
open: vi.fn(),
confirm: vi.fn(),
menu: vi.fn(),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
call: (value: unknown) => value,
commands: {
...real.commands,
settingsGet: mocks.get,
settingsSet: mocks.set,
buildModel: mocks.model,
runConfigs: mocks.configs,
},
};
});
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm }));
vi.mock("@/ui", async (original) => ({ ...(await original<typeof import("@/ui")>()), showContextMenu: mocks.menu }));
const model: BuildModel = {
gradle: null,
maven: {
syncedAt: 1,
stale: false,
syncError: null,
profiles: [],
projects: [
{
pom: "a/pom.xml",
dir: "a",
groupId: "example",
artifactId: "a",
version: "1",
packaging: "pom",
displayName: "project-a",
parent: null,
modules: [],
aggregated: false,
plugins: [],
dependencies: [],
profiles: [],
javaVersion: null,
command: "mvn",
options: [],
env: {},
},
],
},
};
let dispose: () => void;
beforeEach(async () => {
vi.clearAllMocks();
mocks.get.mockResolvedValue({ languages: { java: { maven: { unlinkedProjects: [] } } } });
mocks.set.mockResolvedValue({});
mocks.model.mockResolvedValue(model);
mocks.configs.mockResolvedValue([]);
mocks.confirm.mockResolvedValue(true);
useProject.setState({ project: { root: "/p" } as ProjectInfo });
setContext("projectOpen", true);
await act(async () => {
dispose = activate();
});
});
afterEach(() => {
dispose();
useProject.setState({ project: null });
setContext("projectOpen", false);
});
it("offers unlink on a root's context menu and persists a confirmed project setting", async () => {
render(<MavenView />);
fireEvent.contextMenu(screen.getByText("project-a"));
expect(mocks.menu).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([expect.objectContaining({ action: "maven.unlinkProjects", arg: { pom: "a/pom.xml" } })]),
);
await act(async () => {
await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" });
});
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Unlink Maven projects?" }));
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["a/pom.xml"]');
expect(mocks.model).toHaveBeenCalledTimes(2);
});
it("does not unlink modules or write after cancelled confirmation", async () => {
mocks.confirm.mockResolvedValue(false);
await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" });
expect(mocks.set).not.toHaveBeenCalled();
act(() =>
useBuild.setState({
model: { ...model, maven: { ...model.maven!, projects: [{ ...model.maven!.projects[0]!, aggregated: true }] } },
}),
);
render(<MavenView />);
await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" });
expect(mocks.confirm).toHaveBeenCalledTimes(1);
});
it("re-adds a picked POM using the plus button and preserves other unlinked roots", async () => {
mocks.get.mockResolvedValue({ languages: { java: { maven: { unlinkedProjects: ["a/pom.xml", "b/pom.xml"] } } } });
mocks.open.mockResolvedValue("/p/a/pom.xml");
act(() => useBuild.setState({ model: { ...model, maven: { ...model.maven!, projects: [] } } }));
render(<MavenView />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add Maven Projects" }));
});
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ defaultPath: "/p", directory: false }));
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["b/pom.xml"]');
});
it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/p/a/pom.xml"])(
"ignores cancelled, invalid, outside or already linked picks: %s",
async (picked) => {
mocks.open.mockResolvedValue(picked);
await executeAction("maven.addProjects");
expect(mocks.set).not.toHaveBeenCalled();
},
);
+52 -1
Переглянути файл
@@ -5,6 +5,8 @@
* choices (per project, in localStorage) and running goals through the run
* feature's actions.
*/
import { confirm } from "@/workbench/messages";
import { tx } from "@/platform/i18n";
import { create } from "zustand";
import { Channel } from "@tauri-apps/api/core";
import { executeAction } from "@/platform/actions";
@@ -170,6 +172,54 @@ export async function loadModel(): Promise<void> {
}
}
const UNLINKED_PROJECTS = "languages.java.maven.unlinkedProjects";
async function setLinked(root: string, pom: string, linked: boolean) {
if (useProject.getState().project?.root !== root) return;
const settings = await commands.settingsGet();
if (useProject.getState().project?.root !== root) return;
const previous = settings.languages.java?.maven?.unlinkedProjects ?? [];
const next = linked ? previous.filter((p) => p !== pom) : [...new Set([...previous, pom])];
if (JSON.stringify(next) === JSON.stringify(previous)) return;
await call(commands.settingsSet("project", UNLINKED_PROJECTS, JSON.stringify(next)));
await loadModel();
}
export async function unlinkProjects(pom: string | null) {
const { root, model } = useBuild.getState();
if (!root || !pom || !model?.maven?.projects.some((p) => p.pom === pom && !p.aggregated)) return;
if (
!(await confirm({
title: "Unlink Maven projects?",
message: "Remove this project and its modules from Maven? Files will remain in the Project view.",
ok: "Unlink",
}))
)
return;
cancelSync("maven");
await setLinked(root, pom, false);
}
export async function addProjects() {
const root = useProject.getState().project?.root;
if (!root || !isTauri()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: tx`Add Maven Projects`,
directory: false,
multiple: false,
defaultPath: root,
filters: [{ name: "pom.xml", extensions: ["xml"] }],
});
if (typeof picked !== "string") return;
const base = root.replace(/\\/g, "/").replace(/\/$/, "");
const path = picked.replace(/\\/g, "/");
if (!path.startsWith(`${base}/`)) return;
const pom = path.slice(base.length + 1);
if (pom.split("/").some((part) => part === ".." || part === ".") || pom.split("/").pop() !== "pom.xml") return;
await setLinked(root, pom, true);
}
/** Tree keys expanded on first show: the top-level projects. */
export function initialExpanded(tool: BuildTool, model: BuildModel): Set<string> {
if (tool === "maven")
@@ -182,6 +232,7 @@ const TITLES: Record<BuildTool, string> = { maven: "Syncing Maven projects", gra
/** Reloads all projects of `tool` from the build tool itself (effective POM, dependency tree, task list). */
export async function syncTool(tool: BuildTool): Promise<void> {
const state = useBuild.getState();
const gen = generation;
const root = state.root;
if (!root || !isTauri() || state.syncing[tool]) return;
const request = nextRequestId();
@@ -201,7 +252,7 @@ export async function syncTool(tool: BuildTool): Promise<void> {
const offline = tool === "maven" ? settings.maven.offline : settings.gradle.offline;
try {
const next = await call(commands.buildSync(tool, request, offline, profiles, channel));
if (useBuild.getState().root !== root) return;
if (useBuild.getState().root !== root || gen !== generation) return;
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
useBuild.setState({ model: next, configs });
const error = tool === "maven" ? next.maven?.syncError : next.gradle?.syncError;
+1 -1
Переглянути файл
@@ -31,7 +31,7 @@ describe("languages settings", () => {
expect(v.java.jdtlsVmArgs).toBe("-Xmx2g");
expect(v.java.jdk).toBe("");
expect(v.java.jdtlsJdk).toBe("/opt/jdk-25");
expect(v.java.maven).toEqual({ home: "/opt/maven", userSettings: "", localRepository: "" });
expect(v.java.maven).toEqual({ home: "/opt/maven", userSettings: "", localRepository: "", unlinkedProjects: [] });
expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES);
});
+8 -2
Переглянути файл
@@ -21,7 +21,7 @@ export interface LanguagesValid {
jdk: string;
jdtlsJdk: string;
jdtlsVmArgs: string;
maven: { home: string; userSettings: string; localRepository: string };
maven: { home: string; userSettings: string; localRepository: string; unlinkedProjects: string[] };
};
}
@@ -54,7 +54,12 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" },
go: { goroot: "", gopath: "" },
typescript: { version: "bundled" },
java: { jdk: "", jdtlsJdk: "", jdtlsVmArgs: "", maven: { home: "", userSettings: "", localRepository: "" } },
java: {
jdk: "",
jdtlsJdk: "",
jdtlsVmArgs: "",
maven: { home: "", userSettings: "", localRepository: "", unlinkedProjects: [] },
},
};
export const DEFAULT_PROJECT_STRUCTURE: ProjectStructureValid = {
@@ -136,6 +141,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
home: text(maven.home, d.java.maven.home),
userSettings: text(maven.userSettings, d.java.maven.userSettings),
localRepository: text(maven.localRepository, d.java.maven.localRepository),
unlinkedProjects: folders(maven.unlinkedProjects),
},
},
};
+5
Переглянути файл
@@ -1,4 +1,9 @@
{
"Add Maven Projects": "添加 Maven 项目",
"Unlink Maven Projects": "取消链接 Maven 项目",
"Unlink Maven projects?": "取消链接 Maven 项目?",
"Remove this project and its modules from Maven? Files will remain in the Project view.": "从 Maven 中移除此项目及其模块?文件将保留在项目视图中。",
"Unlink": "取消链接",
"(does not exist)": "(不存在)",
", e.g.": ",例如",
", then press Tab.": ",然后按 Tab。",