fix(build-tools): load Maven and Gradle builds of linked workspace projects (#2187)

The Maven and Gradle tool windows disappeared in a workspace, because detection only inspected the workspace metadata folder. They now discover builds in every linked project; models, caches, Maven profile choices, refreshes and run directories use the owning project, and Java reload notifications target its build files. CMake already handled linked projects. Spring's endpoint filter is unchanged.

Fixes #932
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 03:40:12 +08:00
1 джерело 3dae4ef729
коміт 7579ecbbe1
11 files changed
+704 -128

No files matched your search

+4
Переглянути файл
@@ -5581,11 +5581,15 @@ export type MavenPlugin = {
};
export type MavenProfile = {
/** Owning project folder when displayed in a workspace. */
projectRoot?: string | null,
id: string,
activeByDefault: boolean,
};
export type MavenProject = {
/** Owning project folder when displayed in a workspace. */
projectRoot?: string | null,
/** Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`). */
pom: string,
/** Project-relative directory; `""` = the project root. */
+13 -10
Переглянути файл
@@ -238,7 +238,7 @@ function NodeRow({ node, state }: { node: BuildNode; state: TreeRowState }) {
<span className={styles.row} onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}>
<Checkbox
checked={profileChecked(node.profile, choices)}
onChange={(v) => setProfile(node.profile.id, v)}
onChange={(v) => setProfile(node.profile, v)}
label={node.profile.id}
className={styles.profile}
/>
@@ -246,13 +246,15 @@ function NodeRow({ node, state }: { node: BuildNode; state: TreeRowState }) {
);
}
const detail =
node.kind === "dependency" && node.dependency.scope !== "compile"
? `(${node.dependency.scope}${node.dependency.optional ? ", optional" : ""})`
: node.kind === "dependency" && node.dependency.optional
? "(optional)"
: node.kind === "task"
? node.task.description
: null;
node.kind === "profiles" && node.projectRoot
? node.projectRoot.split("/").pop()
: node.kind === "dependency" && node.dependency.scope !== "compile"
? `(${node.dependency.scope}${node.dependency.optional ? ", optional" : ""})`
: node.kind === "dependency" && node.dependency.optional
? "(optional)"
: node.kind === "task"
? node.task.description
: null;
return (
<span className={styles.row} title={node.kind === "task" ? (node.task.description ?? undefined) : undefined}>
<Icon name={nodeIcon(node)} size={16} className={styles.icon} />
@@ -338,7 +340,8 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
{
label: "Open pom.xml",
icon: "file-maven",
run: () => void editorService().openFile(`${root}/${node.project.pom}`),
run: () =>
void editorService().openFile(node.project.projectRoot ? node.project.pom : `${root}/${node.project.pom}`),
},
{ kind: "action", action: "maven.reload", label: "Reload All Maven Projects" },
{ kind: "action", action: "maven.executeGoal" },
@@ -415,7 +418,7 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
renderNode={(n, state) => <NodeRow node={n} state={state} />}
onActivate={(n) => {
if (n.kind === "profile") {
setProfile(n.profile.id, !profileChecked(n.profile, useBuild.getState().settings.maven.profiles));
setProfile(n.profile, !profileChecked(n.profile, useBuild.getState().settings.maven.profiles));
return;
}
if (isRunnable(n)) run([n], "run");
+21 -11
Переглянути файл
@@ -22,7 +22,15 @@ import { ExecutePopupHost } from "./ExecutePopup";
import { pomNavigationExtension } from "./pomNavigation";
import { buildToolsRunAnything } from "./runAnything";
import { versionCatalogExtension } from "./versionCatalog";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, gradleProjectKey } from "./model";
import {
expandableKeys,
gradleChildren,
gradleRoots,
mavenChildren,
mavenRoots,
mavenPomAtPath,
gradleProjectKey,
} from "./model";
import {
ANALYZER_KIND,
addProjects,
@@ -81,8 +89,8 @@ function analyzerPom(arg: unknown): string | null {
const a = (arg ?? {}) as { pom?: unknown; path?: unknown };
if (typeof a.pom === "string") return a.pom;
if (typeof a.path === "string" && root) {
const rel = a.path.startsWith(`${root}/`) ? a.path.slice(root.length + 1) : a.path;
if (projects.some((p) => p.pom === rel)) return rel;
const pom = mavenPomAtPath(projects, root, a.path);
if (pom) return pom;
}
return current.maven ?? null;
}
@@ -143,10 +151,12 @@ function apply(model: BuildModel | null, registered: Partial<Record<BuildTool, (
}
// Import each independent build once; linking another build must not be hidden by the first sync.
const root = useBuild.getState().root;
const mavenKey = `maven:${root}`;
if (model?.maven?.projects.length && model.maven.syncedAt == null && !synced.has(mavenKey)) {
synced.add(mavenKey);
requestSync("maven");
for (const member of new Set(model?.maven?.projects.map((p) => p.projectRoot ?? null))) {
const key = JSON.stringify(["maven", root, member]);
if (model?.maven?.syncedAt == null && !synced.has(key)) {
synced.add(key);
requestSync("maven", member);
}
}
for (const build of model?.gradle ?? []) {
const key = JSON.stringify([root, build.dir]);
@@ -209,8 +219,8 @@ export function activate(): () => void {
const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null;
if (!path || !path.endsWith("/pom.xml")) return [];
const { model, root } = useBuild.getState();
const pom = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null;
if (!pom || !model?.maven?.projects.some((p) => p.pom === pom)) return [];
const pom = mavenPomAtPath(model?.maven?.projects ?? [], root, path);
if (!pom) return [];
return [{ kind: "action", action: "maven.analyzeDependencies", arg: { pom }, label: "Analyze Dependencies…" }];
},
}),
@@ -269,7 +279,7 @@ export function activate(): () => void {
title: "Add Maven Projects",
category: "Maven",
icon: "plus",
when: "projectOpen",
when: "projectOpen && !workspaceOpen",
run: () => addProjects(),
},
{
@@ -277,7 +287,7 @@ export function activate(): () => void {
title: "Unlink Maven Projects",
category: "Maven",
hidden: true,
when: MAVEN_WHEN,
when: `${MAVEN_WHEN} && !workspaceOpen`,
run: ({ arg }) => unlinkProjects(analyzerPom(arg)),
},
{
+12
Переглянути файл
@@ -12,6 +12,7 @@ import {
mavenChildren,
mavenGoalConfig,
mavenRoots,
mavenPomAtPath,
nodeIcon,
nodeLabel,
parseGoalLine,
@@ -395,3 +396,14 @@ describe("Gradle tree and runs", () => {
expect(taskCompletions("clean wr", gradle).map((c) => c.line)).toEqual(["clean wrapper"]);
});
});
it.each([
["pom.xml", "/workspace/pom.xml"],
["module/pom.xml", "/workspace/module/pom.xml"],
["/linked/pom.xml", "/linked/pom.xml"],
["/workspace/member/pom.xml", "/workspace/member/pom.xml"],
])("resolves the Maven model POM %s at %s", (pom, path) => {
const projects = [project({ pom })];
expect(mavenPomAtPath(projects, "/workspace", path)).toBe(pom);
expect(mavenPomAtPath(projects, "/workspace", "/other/pom.xml")).toBeNull();
});
+23 -6
Переглянути файл
@@ -55,7 +55,7 @@ export const ALL_PHASES = [
];
export type BuildNode =
| { kind: "profiles"; key: string }
| { kind: "profiles"; key: string; projectRoot?: string | null }
| { kind: "profile"; key: string; profile: MavenProfile }
| { kind: "mavenProject"; key: string; project: MavenProject }
| { kind: "lifecycle"; key: string; project: MavenProject }
@@ -76,7 +76,7 @@ export type BuildNode =
export interface MavenRunOptions {
offline: boolean;
skipTests: boolean;
/** Explicit profile choices: id → checked. Unlisted profiles keep their default. */
/** Explicit profile choices by profileKey. Unlisted profiles keep their default. */
profiles: Record<string, boolean>;
}
@@ -100,9 +100,14 @@ export function pluginLabel(p: MavenPlugin): string {
return `${p.prefix} (${p.groupId}:${p.artifactId}${p.version ? `:${p.version}` : ""})`;
}
/** Profile choices in different workspace members are independent. */
export function profileKey(profile: MavenProfile): string {
return profile.projectRoot ? JSON.stringify([profile.projectRoot, profile.id]) : profile.id;
}
/** Whether a profile is active for runs: the explicit choice, else `activeByDefault`. */
export function profileChecked(profile: MavenProfile, choices: Record<string, boolean>): boolean {
return choices[profile.id] ?? profile.activeByDefault;
return choices[profileKey(profile)] ?? profile.activeByDefault;
}
/** `-P` value for the choices that differ from the defaults (`a,!b`), or null. */
@@ -123,11 +128,17 @@ export function profilesList(profiles: readonly MavenProfile[], choices: Record<
export function mavenRoots(model: MavenModel): BuildNode[] {
const out: BuildNode[] = [];
if (model.profiles.length) out.push({ kind: "profiles", key: "mvn:profiles" });
for (const projectRoot of new Set(model.profiles.map((p) => p.projectRoot)))
out.push({ kind: "profiles", key: `mvn:profiles${projectRoot ?? ""}`, projectRoot });
for (const p of model.projects) if (!p.aggregated) out.push({ kind: "mavenProject", key: projectKey(p), project: p });
return out;
}
/** Resolves a POM from either a project-relative or workspace-absolute model path. */
export function mavenPomAtPath(projects: readonly MavenProject[], root: string | null, path: string): string | null {
return projects.find((p) => p.pom === path || (root && `${root}/${p.pom}` === path))?.pom ?? null;
}
/** Run configurations of a Maven project: `maven` configs whose directory is the project's. */
export function mavenRunConfigs(project: MavenProject, configs: readonly RunConfig[]): RunConfig[] {
return configs.filter((c) => c.kind === "maven" && normalizeDir(c.cwd) === project.dir);
@@ -171,7 +182,9 @@ export function mavenChildren(
): BuildNode[] | undefined {
switch (node.kind) {
case "profiles":
return model.profiles.map((profile) => ({ kind: "profile", key: `mvn:profile:${profile.id}`, profile }));
return model.profiles
.filter((p) => p.projectRoot === node.projectRoot)
.map((profile) => ({ kind: "profile", key: `mvn:profile:${profileKey(profile)}`, profile }));
case "mavenProject": {
const p = node.project;
const k = projectKey(p);
@@ -474,7 +487,11 @@ export function mavenGoalConfig(
model: Pick<MavenModel, "profiles">,
options: MavenRunOptions,
): RunConfig {
const args = mavenArgs(goals, model, options);
const args = mavenArgs(
goals,
{ profiles: model.profiles.filter((p) => p.projectRoot === project.projectRoot) },
options,
);
return {
id: `maven:goal:${project.pom}:${goals.join(" ")}`,
name: mavenConfigName(project, goals),
+33 -9
Переглянути файл
@@ -18,6 +18,7 @@ import {
nextRequestId,
type BuildModel,
type BuildTool,
type MavenProfile,
type RequestId,
type RunConfig,
type SyncProgress,
@@ -27,7 +28,7 @@ import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
import { editorService, notifications } from "@/workbench/contributions";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { gradleRoots, profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
import { gradleRoots, profileKey, profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
export interface BuildSettings {
maven: MavenRunOptions;
@@ -126,15 +127,20 @@ export const toggleGradleOffline = () =>
/** Delay before reloading Maven after a profile toggle, so a few toggles in a row reload once. */
export const PROFILE_RELOAD_DELAY_MS = 800;
let profileReload: ReturnType<typeof setTimeout> | undefined;
const profileRoots = new Set<string | null>();
export function setProfile(id: string, checked: boolean) {
export function setProfile(profile: string | MavenProfile, checked: boolean) {
const id = typeof profile === "string" ? profile : profileKey(profile);
const projectRoot = typeof profile === "string" ? null : (profile.projectRoot ?? null);
updateSettings((s) => ({ ...s, maven: { ...s.maven, profiles: { ...s.maven.profiles, [id]: checked } } }));
// The Java language server and Build Project resolve the project with the profiles of the
// last sync, so a new choice reloads it (IDEA re-imports on a profile change too).
profileRoots.add(projectRoot);
clearTimeout(profileReload);
profileReload = setTimeout(() => {
profileReload = undefined;
requestSync("maven");
for (const root of profileRoots) requestSync("maven", root);
profileRoots.clear();
}, PROFILE_RELOAD_DELAY_MS);
}
@@ -240,7 +246,7 @@ const LINKED_PROJECTS = "languages.java.maven.linkedProjects";
* reactor lists them), or removes them (a linked one is dropped, a discovered one unlinked).
*/
async function setLinked(root: string, poms: string[], linked: boolean) {
if (useProject.getState().project?.root !== root) return;
if (useProject.getState().project?.root !== root || useProject.getState().project?.workspace) return;
const settings = await commands.settingsGet();
if (useProject.getState().project?.root !== root) return;
const maven = settings.languages.java?.maven;
@@ -266,7 +272,7 @@ async function setLinked(root: string, poms: string[], linked: boolean) {
export async function unlinkProjects(pom: string | null) {
const { root, model } = useBuild.getState();
const project = model?.maven?.projects.find((p) => p.pom === pom);
if (!root || !pom || !project) return;
if (!root || !pom || !project || useProject.getState().project?.workspace) return;
if (
!(await confirm({
title: "Unlink Maven projects?",
@@ -295,7 +301,7 @@ function projectPoms(root: string, picked: readonly string[]): string[] {
export async function addProjects() {
const root = useProject.getState().project?.root;
if (!root || !isTauri()) return;
if (!root || useProject.getState().project?.workspace || !isTauri()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: tx`Add Maven Projects`,
@@ -392,7 +398,7 @@ export function requestSync(tool: BuildTool, buildDir: string | null = null) {
} else void syncTool(tool, buildDir);
}
/** Reloads the build tool model; `buildDir` limits a Gradle reload to one linked build. */
/** Reloads one Maven workspace member / linked Gradle build, or all builds when `buildDir` is null. */
export async function syncTool(tool: BuildTool, buildDir: string | null = null): Promise<void> {
const state = useBuild.getState();
const gen = generation;
@@ -411,10 +417,27 @@ export async function syncTool(tool: BuildTool, buildDir: string | null = null):
upsertTask({ id: taskId, title: TITLES[tool], message: p.message, fraction: p.fraction, done: false, cancel });
};
const { settings, model } = state;
const profiles = tool === "maven" && model?.maven ? profilesList(model.maven.profiles, settings.maven.profiles) : [];
const offline = tool === "maven" ? settings.maven.offline : settings.gradle.offline;
try {
const next = await call(commands.buildSync(tool, request, offline, profiles, buildDir, channel));
const roots =
tool === "maven" && useProject.getState().project?.workspace
? [...new Set(model?.maven?.projects.map((p) => p.projectRoot ?? null))].filter(
(r) => !buildDir || r === buildDir,
)
: [buildDir];
let next = model;
for (const dir of roots) {
if (useBuild.getState().root !== root || useBuild.getState().syncing[tool]?.request !== request) return;
const profiles =
tool === "maven" && model?.maven
? profilesList(
model.maven.profiles.filter((p) => (p.projectRoot ?? null) === dir),
settings.maven.profiles,
)
: [];
next = await call(commands.buildSync(tool, request, offline, profiles, dir, channel));
}
if (!next) return;
if (useBuild.getState().root !== root || gen !== generation) return;
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
if (useBuild.getState().root !== root || gen !== generation) return;
@@ -472,6 +495,7 @@ export function resetBuild() {
generation++;
clearTimeout(profileReload);
profileReload = undefined;
profileRoots.clear();
queuedSyncs.clear();
useAnalyzer.setState({ module: null });
for (const tool of ["maven", "gradle"] as const) {
+214
Переглянути файл
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { BuildModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { registerAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import { MavenView, GradleView } from "./BuildToolView";
import { activate } from "./index";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, profileKey } from "./model";
import { executeBuildLine } from "./runAnything";
import { loadModel, PROFILE_RELOAD_DELAY_MS, resetBuild, setProfile, syncTool, useBuild } from "./store";
const mocks = vi.hoisted(() => ({ model: vi.fn(), sync: 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,
buildModel: mocks.model,
runConfigs: () => Promise.resolve([]),
buildSync: mocks.sync,
cancelRequest: () => Promise.resolve(),
},
};
});
vi.mock("@tauri-apps/api/core", async (original) => ({
...(await original<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage: unknown = null;
},
}));
const members = ["/projects/first", "/projects/second"];
const model: BuildModel = {
maven: {
syncedAt: 1,
stale: false,
syncError: null,
profiles: members.map((projectRoot) => ({ id: "local", activeByDefault: false, projectRoot })),
projects: members.map((projectRoot) => ({
projectRoot,
pom: `${projectRoot}/pom.xml`,
dir: projectRoot,
groupId: "example",
artifactId: "app",
version: "1",
packaging: "jar",
displayName: projectRoot.split("/").pop()!,
parent: null,
modules: [],
aggregated: false,
plugins: [],
dependencies: [],
profiles: ["local"],
javaVersion: null,
command: `${projectRoot}/mvnw`,
options: [],
env: {},
})),
},
gradle: members.map((dir) => ({
dir,
command: `${dir}/gradlew`,
env: {},
javaVersion: null,
syncedAt: 1,
stale: false,
syncError: null,
projects: [
{
path: ":",
name: dir.split("/").pop()!,
dir,
buildFile: `${dir}/build.gradle`,
tasks: [{ name: "test", group: "verification", description: null }],
},
],
})),
};
let dispose: () => void;
beforeEach(async () => {
vi.clearAllMocks();
localStorage.clear();
resetBuild();
useProject.setState({
project: {
root: "/workspace",
trusted: true,
workspace: { version: 1, name: "Workspace", projects: members },
} as ProjectInfo,
});
setContext("projectOpen", true);
setContext("workspaceOpen", true);
mocks.model.mockResolvedValue(model);
mocks.sync.mockResolvedValue(model);
await act(async () => {
dispose = activate();
});
});
afterEach(() => {
dispose();
resetBuild();
useProject.setState({ project: null });
setContext("projectOpen", false);
setContext("workspaceOpen", false);
vi.useRealTimers();
});
it("lists linked Maven projects and keeps duplicate profile choices and goal directories independent", async () => {
const maven = model.maven!;
const roots = mavenRoots(maven);
act(() =>
useBuild.setState((s) => ({
expanded: { ...s.expanded, maven: expandableKeys(roots, (n) => mavenChildren(n, maven, [])) },
})),
);
render(<MavenView />);
expect(screen.getAllByText("first")).toHaveLength(2);
expect(screen.getAllByText("second")).toHaveLength(2);
const profiles = screen.getAllByRole("checkbox", { name: "local" });
expect(profiles).toHaveLength(2);
vi.useFakeTimers();
await act(async () => {
fireEvent.click(profiles[1]!);
});
expect(profiles[0]).not.toBeChecked();
expect(profiles[1]).toBeChecked();
await act(async () => {
await vi.advanceTimersByTimeAsync(PROFILE_RELOAD_DELAY_MS);
});
expect(mocks.sync).toHaveBeenCalledWith("maven", expect.any(Number), false, ["local"], members[1], expect.anything());
const runs: RunConfig[] = [];
const off = registerAction({
id: "run.runConfig",
title: "Run",
hidden: true,
run: ({ arg }) => {
runs.push((arg as { config: RunConfig }).config);
},
});
await act(async () => {
fireEvent.doubleClick(screen.getAllByText("validate")[1]!);
});
expect(executeBuildLine("maven", "validate", maven.projects[0]!.pom)).toBe(true);
off();
expect(runs[0]).toMatchObject({ cwd: members[1], command: `${members[1]}/mvnw`, args: ["validate", "-Plocal"] });
expect(runs[1]).toMatchObject({ cwd: members[0], command: `${members[0]}/mvnw`, args: ["validate"] });
});
it("reloads all Maven members with their own profile choices", async () => {
vi.useFakeTimers();
setProfile(model.maven!.profiles[0]!, true);
setProfile(model.maven!.profiles[1]!, true);
await vi.advanceTimersByTimeAsync(PROFILE_RELOAD_DELAY_MS);
expect(mocks.sync.mock.calls.map((c) => [c[3], c[4]])).toEqual([
[["local"], members[0]],
[["local"], members[1]],
]);
mocks.sync.mockClear();
useBuild.setState((s) => ({
settings: {
...s.settings,
maven: { ...s.settings.maven, profiles: { [profileKey(model.maven!.profiles[1]!)]: true } },
},
}));
await syncTool("maven");
expect(mocks.sync.mock.calls.map((c) => [c[3], c[4]])).toEqual([
[[], members[0]],
[["local"], members[1]],
]);
});
it("shows and runs duplicate Gradle tasks with each member's wrapper and refresh directory", async () => {
const roots = gradleRoots(model.gradle ?? []);
act(() =>
useBuild.setState((s) => ({
expanded: {
...s.expanded,
gradle: expandableKeys(roots, (n) => gradleChildren(n, model.gradle ?? [], [], "/workspace")),
},
})),
);
render(<GradleView />);
const runs: RunConfig[] = [];
const off = registerAction({
id: "run.runConfig",
title: "Run",
hidden: true,
run: ({ arg }) => {
runs.push((arg as { config: RunConfig }).config);
},
});
await act(async () => {
fireEvent.doubleClick(screen.getAllByText("test")[1]!);
});
off();
expect(runs[0]).toMatchObject({ cwd: members[1], command: `${members[1]}/gradlew`, args: ["test"] });
await syncTool("gradle", members[1]);
expect(mocks.sync).toHaveBeenCalledWith("gradle", expect.any(Number), false, [], members[1], expect.anything());
});
it("refreshes availability after workspace membership changes and does not load untrusted windows", async () => {
mocks.model.mockResolvedValue({ maven: null, gradle: [] });
await loadModel();
expect(useBuild.getState().model).toEqual({ maven: null, gradle: [] });
mocks.model.mockClear();
act(() => useProject.setState({ project: { ...useProject.getState().project!, trusted: false } }));
await loadModel();
expect(mocks.model).not.toHaveBeenCalled();
expect(useBuild.getState().model).toBeNull();
});