дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
c55fd9b86e
коміт
2ea47873cf
21 files changed
+563
-19
No files matched your search
@@ -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",
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("build tools feature", () => {
|
||||
.map((a) => a.id)
|
||||
.sort(),
|
||||
).toEqual([
|
||||
"maven.addProjects",
|
||||
"maven.analyzeDependencies",
|
||||
"maven.executeGoal",
|
||||
"maven.reload",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача