feat(gradle): link and unlink independent Gradle builds (#1413)

Opening a folder of independent Gradle builds selected only the first build. Link Gradle Projects… (pick a build's settings or build file) and Unlink on a build's root let an ordinary project window manage several independent builds without changing their scripts. The build model holds a list of Gradle builds; each keeps its own wrapper, working directory, environment, task routing and sync cache, keyed by the owning build directory. The links persist in the project's Gradle settings (linkedProjects); unlinking leaves files and saved run configurations alone. Existing includeBuild support is unchanged, and an explicitly linked build takes precedence over a composite that also includes it. Workspace windows keep Link/Unlink disabled.

Co-authored-by: gudcks0305 <[email protected]>
Цей коміт міститься в:
yoo hyeong chan authored and GitHub committed 2026-11-05 11:23:39 +08:00
1 джерело 682fee633d
коміт 0dfdbbc323
43 files changed
+1846 -254

No files matched your search

+2 -1
Переглянути файл
@@ -50,7 +50,8 @@ async function ensureConfigs() {
export function projectContentRoots(): string[] {
const own = validateProjectStructure(useProject.getState().settings?.projectStructure).contentRoots;
// The build model keeps the folders of modules outside the project absolute.
const gradle = (useBuildModel.getState().model?.gradle?.projects ?? [])
const gradle = (useBuildModel.getState().model?.gradle ?? [])
.flatMap((g) => g.projects)
.map((p) => p.dir)
.filter((d) => isAbsolute(d));
return [...new Set([...own, ...gradle])];
+76 -3
Переглянути файл
@@ -1,9 +1,31 @@
import { describe, expect, it } from "vitest";
import type { GradleModel, RunConfig } from "@/platform/ipc";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { applicationConfig } from "./application";
import { DELEGATED_DEBUG, delegatedGradleConfig, withTestEvents, wrapperGradleVersion } from "./gradleRun";
import {
DELEGATED_DEBUG,
delegatedGradleConfig,
gradleDelegation,
withTestEvents,
wrapperGradleVersion,
} from "./gradleRun";
import { jdwpLaunch } from "./javaDebug";
import { isJvmApplication } from "@/platform/javaLaunch";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
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,
runGradleInitScript: () => Promise.resolve("/tmp/openidea-gradle-run.gradle"),
fsReadText: () => Promise.resolve("distributionUrl=https://x/gradle-8.10-bin.zip"),
},
};
});
const ROOT = "/w/shop";
const CORE = "/w/billing/modules/core";
@@ -143,3 +165,54 @@ describe("Gradle test runs", () => {
expect(withTestEvents(twice, script)).toBe(twice);
});
});
describe("delegation across independent builds", () => {
afterEach(() => {
useProject.setState({ project: null, settings: null });
useBuildModel.setState({ root: null, model: null, gradleOffline: false });
});
it("selects the module's linked build and preserves composite identity task paths", async () => {
const builds = ["first", "second"].map((dir) => ({
...gradle,
dir,
command: `${ROOT}/${dir}/gradlew`,
env: { JAVA_HOME: `/${dir}/jdk` },
projects: [{ path: ":app", name: "app", dir: `${dir}/app`, buildFile: null, tasks: [] }],
}));
useProject.setState({ project: { root: ROOT } as ProjectInfo, settings: null });
useBuildModel.setState({ root: ROOT, model: { maven: null, gradle: builds }, gradleOffline: true });
const app = {
...applicationConfig({ className: "a.Main" }, "second/app/src/main/java/a/Main.java", []),
module: "second/app",
};
const run = await gradleDelegation(app);
expect(run).toMatchObject({ command: `${ROOT}/second/gradlew`, cwd: "second", env: { JAVA_HOME: "/second/jdk" } });
expect(run!.args).toContain("--offline");
expect(run!.args.at(-1)).toBe(":app:openideaRun");
});
it("prefers the directly linked owner over a longer sibling composite root", async () => {
const composite = {
...gradle,
dir: "very-long-composite",
command: `${ROOT}/very-long-composite/gradlew`,
projects: [{ path: ":b:app", name: "app", dir: "b/app", buildFile: null, tasks: [], build: ":b" }],
};
const direct = {
...gradle,
dir: "b",
command: `${ROOT}/b/gradlew`,
projects: [{ path: ":app", name: "app", dir: "b/app", buildFile: null, tasks: [] }],
};
useProject.setState({ project: { root: ROOT } as ProjectInfo, settings: null });
useBuildModel.setState({ root: ROOT, model: { maven: null, gradle: [composite, direct] } });
const app = {
...applicationConfig({ className: "a.Main" }, "b/app/src/main/java/a/Main.java", []),
module: "b/app",
};
const run = await gradleDelegation(app);
expect(run).toMatchObject({ command: `${ROOT}/b/gradlew`, cwd: "b" });
expect(run!.args.at(-1)).toBe(":app:openideaRun");
});
});
+13 -2
Переглянути файл
@@ -135,12 +135,23 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
const { project, settings } = useProject.getState();
if (!project?.root || settings?.languages?.java?.gradle?.runUsing === "ide") return null;
const { model, gradleOffline } = useBuildModel.getState();
if (!model?.gradle) return null;
if (!model?.gradle?.length) return null;
const module = config.module ?? (await findJavaModule(config));
if (module === null) return null;
const dir = absolute(project.root, module);
// A composite can report a sibling's module; prefer a linked root that contains it.
const ownerDepth = (g: GradleModel) => {
const root = absolute(project.root, g.dir).replace(/\\/g, "/").replace(/\/+$/, "");
const path = dir.replace(/\\/g, "/").replace(/\/+$/, "");
return path === root || path.startsWith(`${root}/`) ? root.split("/").length : 0;
};
const gradle = model.gradle
.filter((g) => g.projects.some((p) => absolute(project.root, p.dir) === dir))
.sort((a, b) => ownerDepth(b) - ownerDepth(a))[0];
if (!gradle) return null;
const ctx = {
root: project.root,
gradle: model.gradle,
gradle,
script: "",
offline: gradleOffline,
configurationCache: false,