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

+7 -3
Переглянути файл
@@ -664,14 +664,16 @@ export const commands = {
runTestReports: (module: string | null, sinceMs: number | null) => typedError<TestCaseReport[], IdeError>(__TAURI_INVOKE("run_test_reports", { module, sinceMs })),
/** Maven and Gradle projects of the window's project, from the build files plus the last sync. */
buildModel: () => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_model")),
gradleLinkProjects: (paths: string[]) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("gradle_link_projects", { paths })),
gradleUnlinkProjects: (paths: string[]) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("gradle_unlink_projects", { paths })),
/**
* Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
* background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
* Returns the refreshed model; a failed build is reported in its `syncError`.
*/
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], buildDir: string | null, onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, buildDir, onProgress })),
/** The resolved dependency tree of a Gradle project for one configuration (Dependency Analyzer). */
gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId) => typedError<GradleDependency[], IdeError>(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request })),
gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId, buildDir: string | null) => typedError<GradleDependency[], IdeError>(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request, buildDir })),
/**
* The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
* build has downloaded it.
@@ -1858,7 +1860,7 @@ export type BuildMessage = {
/** The build systems of a project; `None` when the project does not use one. */
export type BuildModel = {
maven: MavenModel | null,
gradle: GradleModel | null,
gradle?: GradleModel[],
};
export type BuildResult = {
@@ -3924,6 +3926,8 @@ export type GradleRunUsing =
/** Build Tools › Gradle. */
export type GradleSettings = {
/** Linked build directories; absent preserves automatic discovery, empty unlinks all builds. */
linkedProjects?: string[] | null,
/** Empty = Project JDK; otherwise a JDK home. */
jvm?: string,
distribution?: GradleDistribution,
+46 -23
Переглянути файл
@@ -21,6 +21,7 @@ import {
import {
gradleChildren,
gradleRoots,
gradleProjectKey,
gradleTaskConfig,
isRunnable,
mavenChildren,
@@ -56,7 +57,7 @@ export function GradleView() {
/** The run configuration a runnable node (or several of one project) starts. */
export function nodeConfig(
nodes: readonly BuildNode[],
model: { maven?: MavenModel | null; gradle?: GradleModel | null },
model: { maven?: MavenModel | null; gradle?: readonly GradleModel[] },
) {
const settings = useBuild.getState().settings;
const first = nodes[0];
@@ -71,13 +72,17 @@ export function nodeConfig(
const goals = same.map((n) => (n.kind === "phase" ? n.phase : `${n.plugin.prefix}:${n.goal}`));
return mavenGoalConfig(first.project, goals, model.maven, settings.maven);
}
if (first.kind === "task" && model.gradle) {
if (first.kind === "task") {
const build = model.gradle?.find((g) => g.dir === first.build.dir);
const project = build?.projects.find((p) => p.path === first.project.path);
if (!build || !project) return null;
const same = nodes.filter(
(n): n is Extract<BuildNode, { kind: "task" }> => n.kind === "task" && n.project.path === first.project.path,
(n): n is Extract<BuildNode, { kind: "task" }> =>
n.kind === "task" && n.build.dir === first.build.dir && n.project.path === first.project.path,
);
return gradleTaskConfig(
model.gradle,
first.project,
build,
project,
same.map((n) => n.task.name),
settings.gradle,
);
@@ -89,8 +94,9 @@ export function nodeConfig(
function childrenOf(
tool: BuildTool,
maven: MavenModel | null,
gradle: GradleModel | null,
gradle: readonly GradleModel[],
configs: readonly RunConfig[],
root: string,
) {
const cache = new Map<string, BuildNode[] | undefined>();
return (n: BuildNode) => {
@@ -101,7 +107,7 @@ function childrenOf(
? mavenChildren(n, maven, configs)
: undefined
: gradle
? gradleChildren(n, gradle, configs)
? gradleChildren(n, gradle, configs, root)
: undefined;
cache.set(n.key, kids);
return kids;
@@ -110,7 +116,8 @@ function childrenOf(
/** Maven `pom` / Gradle project path of the project a node belongs to. */
function nodeProject(n: BuildNode): string | undefined {
if ("project" in n) return "pom" in n.project ? n.project.pom : n.project.path;
if ("project" in n)
return "pom" in n.project ? n.project.pom : "build" in n ? gradleProjectKey(n.build, n.project) : undefined;
return undefined;
}
@@ -118,9 +125,8 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
const syncing = useBuild((s) => !!s.syncing[tool]);
const settings = useBuild((s) => s.settings);
const model = useBuild((s) => s.model);
const runnable = selection.filter(isRunnable);
const config = model && nodeConfig(selection.filter(isRunnable), model);
const runSelected = () => {
const config = model && nodeConfig(runnable, model);
if (config) runConfig(config, "run");
};
const maven = tool === "maven";
@@ -131,14 +137,14 @@ 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" />}
<IconButton icon="plus" size="small" iconSize={16} action={maven ? "maven.addProjects" : "gradle.linkProjects"} />
<span className={styles.sep} />
<IconButton
icon="run"
size="small"
iconSize={16}
title={maven ? "Run Maven Build" : "Run Gradle Task"}
disabled={!runnable.length}
disabled={!config}
onClick={runSelected}
/>
<IconButton
@@ -181,7 +187,8 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
function Banner({ tool }: { tool: BuildTool }) {
const sync = useBuild((s) => s.syncing[tool]);
const info = useBuild((s) => (tool === "maven" ? s.model?.maven : s.model?.gradle));
const model = useBuild((s) => s.model);
const infos = tool === "maven" ? (model?.maven ? [model.maven] : []) : (model?.gradle ?? []);
if (sync)
return (
<div className={styles.banner} role="status">
@@ -190,7 +197,10 @@ function Banner({ tool }: { tool: BuildTool }) {
{sync.fraction != null && <span className={styles.bannerPct}>{Math.round(sync.fraction * 100)}%</span>}
</div>
);
if (!info) return null;
return infos.map((info) => <BuildBanner key={"dir" in info ? info.dir : "maven"} info={info} tool={tool} />);
}
function BuildBanner({ info, tool }: { info: MavenModel | GradleModel; tool: BuildTool }) {
if (info.stale)
return (
<div className={styles.banner}>
@@ -198,7 +208,7 @@ function Banner({ tool }: { tool: BuildTool }) {
<span className={styles.bannerText}>
<T>Build files changed since the last reload</T>
</span>
<Button size="small" variant="ghost" onClick={() => void syncTool(tool)}>
<Button size="small" variant="ghost" onClick={() => void syncTool(tool, "dir" in info ? info.dir : null)}>
{tool === "maven" ? "Load Maven Changes" : "Load Gradle Changes"}
</Button>
</div>
@@ -260,15 +270,18 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
const root = useProject((s) => s.project?.root ?? "");
const [selection, setSelection] = useState<BuildNode[]>([]);
const maven = model?.maven ?? null;
const gradle = model?.gradle ?? null;
const gradle = model?.gradle;
const roots = useMemo(
() => (tool === "maven" ? (maven ? mavenRoots(maven) : []) : gradle ? gradleRoots(gradle) : []),
() => (tool === "maven" ? (maven ? mavenRoots(maven) : []) : gradleRoots(gradle ?? [])),
[tool, maven, gradle],
);
const getChildren = useMemo(() => childrenOf(tool, maven, gradle, configs), [tool, maven, gradle, configs]);
const getChildren = useMemo(
() => childrenOf(tool, maven, gradle ?? [], configs, root),
[tool, maven, gradle, configs, root],
);
const hasModel = tool === "maven" ? !!maven : !!gradle;
const hasModel = tool === "maven" ? !!maven : !!gradle?.length;
useEffect(() => {
if (model && hasModel && useBuild.getState().expanded[tool].size === 0)
setExpanded(tool, initialExpanded(tool, model));
@@ -331,12 +344,22 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
items.push({
label: tx`Open ${file.split("/").pop()}`,
icon: "file-gradle",
run: () => void editorService().openFile(`${root}/${file}`),
run: () => void editorService().openFile(/^(?:[A-Za-z]:[\\/]|\/)/.test(file) ? file : `${root}/${file}`),
});
if (node.project.path === ":")
items.push({ kind: "action", action: "gradle.unlinkProjects", arg: { buildDir: node.build.dir } });
items.push(
{ kind: "action", action: "gradle.reload", label: "Reload All Gradle Projects" },
{ kind: "action", action: "gradle.executeTask" },
{ kind: "action", action: "gradle.analyzeDependencies", arg: { gradlePath: node.project.path } },
{ kind: "action", action: "gradle.reload", label: "Reload Gradle Project", arg: { buildDir: node.build.dir } },
{
kind: "action",
action: "gradle.executeTask",
arg: { projectKey: gradleProjectKey(node.build, node.project) },
},
{
kind: "action",
action: "gradle.analyzeDependencies",
arg: { buildDir: node.build.dir, gradlePath: node.project.path },
},
);
}
if (node.kind === "dependency") {
+31 -8
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, GradleDependency, GradleModel, MavenDependency } from "@/platform/ipc";
@@ -72,7 +72,7 @@ const model: BuildModel = {
stale: false,
syncError: null,
},
gradle: null,
gradle: [],
};
describe("dependency analyzer", () => {
@@ -159,7 +159,7 @@ describe("dependency analyzer for Gradle", () => {
provideEditorService({ openInput, activePath: () => null } as unknown as EditorService);
setContext("projectOpen", true);
dispose = activate();
act(() => useBuild.setState({ root: "/g", model: { maven: null, gradle } }));
act(() => useBuild.setState({ root: "/g", model: { maven: null, gradle: [gradle] } }));
});
afterEach(() => {
dispose();
@@ -170,13 +170,13 @@ describe("dependency analyzer for Gradle", () => {
it("opens for a project from the action and from its build script's menus", async () => {
await executeAction("gradle.analyzeDependencies", { gradlePath: ":app" });
expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: ANALYZER_KIND }), { focus: true });
expect(useAnalyzer.getState().module).toBe("gradle::app");
expect(useAnalyzer.getState().module).toBe('gradle:["",":app"]');
const items = menuGroupItems("projectView", { paths: ["/g/app/build.gradle.kts"] });
expect(items).toContainEqual(
expect.objectContaining({ action: "gradle.analyzeDependencies", arg: { path: "/g/app/build.gradle.kts" } }),
);
await executeAction("gradle.analyzeDependencies", { path: "/g/build.gradle" });
expect(useAnalyzer.getState().module).toBe("gradle::");
expect(useAnalyzer.getState().module).toBe('gradle:["",":"]');
expect(menuGroupItems("editor", { paths: ["/g/other/build.gradle"] })).toEqual([]);
});
@@ -187,9 +187,9 @@ describe("dependency analyzer for Gradle", () => {
]),
gradleDep("com.fasterxml.jackson.core:jackson-databind:2.15.3", null),
]);
act(() => useAnalyzer.setState({ module: "gradle::app" }));
act(() => useAnalyzer.setState({ module: 'gradle:["",":app"]' }));
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number));
expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number), "");
await screen.findByText("com.fasterxml.jackson.core:jackson-databind");
fireEvent.click(screen.getByRole("checkbox", { name: "Show conflicts only" }));
expect(screen.queryByText("org.springframework.boot:spring-boot-starter-json")).toBeNull();
@@ -203,9 +203,32 @@ describe("dependency analyzer for Gradle", () => {
expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull();
});
it("routes identical project paths and report caches by linked build", async () => {
const builds = ["one", "two"].map((dir) => ({
...gradle,
dir,
syncedAt: 1,
projects: gradle.projects.map((p) => ({ ...p, dir: `${dir}/${p.dir}`, buildFile: `${dir}/${p.buildFile}` })),
}));
ipc.gradle.mockResolvedValue([]);
act(() => {
useBuild.setState({ model: { maven: null, gradle: builds } });
useAnalyzer.setState({ module: 'gradle:["one",":app"]' });
});
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
await waitFor(() =>
expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number), "one"),
);
act(() => useAnalyzer.setState({ module: 'gradle:["two",":app"]' }));
await waitFor(() =>
expect(ipc.gradle).toHaveBeenLastCalledWith(":app", "runtimeClasspath", false, expect.any(Number), "two"),
);
expect(ipc.gradle).toHaveBeenCalledTimes(2);
});
it("shows why Gradle could not resolve the configuration", async () => {
ipc.gradle.mockRejectedValue(new Error("Could not resolve all files for configuration ':app:runtimeClasspath'."));
act(() => useAnalyzer.setState({ module: "gradle::" }));
act(() => useAnalyzer.setState({ module: 'gradle:["",":"]' }));
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
expect(await screen.findByText("Cannot resolve dependencies")).toBeTruthy();
expect(screen.getByText(/Could not resolve all files/)).toBeTruthy();
+29 -21
Переглянути файл
@@ -11,7 +11,7 @@ import {
commands,
isCancelled,
nextRequestId,
type GradleProject,
type GradleModel,
type MavenDependency,
type MavenProject,
} from "@/platform/ipc";
@@ -53,6 +53,7 @@ import {
type DependencyTreeNode,
type DependencyUsage,
} from "./analyzer";
import { gradleProjectKey } from "./model";
import { syncTool, useAnalyzer, useBuild } from "./store";
import styles from "./DependencyAnalyzer.module.css";
@@ -67,13 +68,13 @@ const GRADLE = "gradle:";
const CONFIGURATIONS = ["compileClasspath", "runtimeClasspath", "testCompileClasspath", "testRuntimeClasspath"];
type GradleTree = { deps: MavenDependency[] | null; error: string | null; loading: boolean };
/** Reports already read in this session, by `root|path|configuration`: reopening the tab doesn't run Gradle again. */
/** Reports already read in this session, by workspace, build, project and configuration: reopening the tab doesn't run Gradle again. */
const gradleTrees = new Map<string, MavenDependency[]>();
/** The `dependencies` report of `path` for `configuration`, cached; a new `generation` runs Gradle again. */
function useGradleTree(path: string | null, configuration: string, generation: number) {
function useGradleTree(path: string | null, buildDir: string | null, configuration: string, generation: number) {
const root = useBuild((s) => s.root);
const key = path && root ? `${root}|${path}|${configuration}` : null;
const key = path && root ? JSON.stringify([root, buildDir, path, configuration]) : null;
const [state, setState] = useState<GradleTree & { key: string | null }>({
key: null,
deps: null,
@@ -98,7 +99,7 @@ function useGradleTree(path: string | null, configuration: string, generation: n
let done = false;
setState({ key, deps: cached ?? null, error: null, loading: true });
const offline = useBuild.getState().settings.gradle.offline;
call(commands.gradleDependencies(path, configuration, offline, request))
call(commands.gradleDependencies(path, configuration, offline, request, buildDir))
.then((deps) => {
const tree = fromGradle(deps);
gradleTrees.set(key, tree);
@@ -115,7 +116,7 @@ function useGradleTree(path: string | null, configuration: string, generation: n
live = false;
if (!done) void commands.cancelRequest(request).catch(() => {});
};
}, [key, path, configuration, generation]);
}, [key, path, buildDir, configuration, generation]);
const current: GradleTree = state.key === key ? state : { deps: null, error: null, loading: !!key };
const cancel = () => {
if (running.current != null) void commands.cancelRequest(running.current).catch(() => {});
@@ -162,20 +163,22 @@ function versionLabel(d: MavenDependency) {
return d.version ?? "";
}
function moduleOptions(maven: readonly MavenProject[], gradle: readonly GradleProject[]): SelectOption[] {
function moduleOptions(maven: readonly MavenProject[], gradle: readonly GradleModel[]): SelectOption[] {
return [
...maven
.filter((p) => p.packaging !== "pom" || p.dependencies.length > 0)
.map((p) => ({ value: p.pom, label: p.displayName, detail: p.pom, icon: "maven-project" })),
// buildSrc's tasks can't be run from the build.
...gradle
.filter((p) => p.build !== ":buildSrc")
.map((p) => ({
value: `${GRADLE}${p.path}`,
label: p.name,
detail: p.path,
icon: p.path === ":" ? "gradle-project" : "gradle-module",
})),
...gradle.flatMap((build) =>
build.projects
.filter((p) => !p.build?.endsWith(":buildSrc"))
.map((p) => ({
value: gradleProjectKey(build, p),
label: p.name,
detail: `${build.dir || "."} ${p.path}`,
icon: p.path === ":" ? "gradle-project" : "gradle-module",
})),
),
];
}
@@ -332,7 +335,7 @@ function UsagesPane({
export function DependencyAnalyzer(_props: { input: EditorInput }) {
const tx = useTx();
const maven = useBuild((s) => s.model?.maven ?? null);
const gradle = useBuild((s) => s.model?.gradle ?? null);
const gradle = useBuild((s) => s.model?.gradle);
const syncing = useBuild((s) => s.syncing.maven);
const chosen = useAnalyzer((s) => s.module);
const [query, setQuery] = useState("");
@@ -343,13 +346,18 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
const [mode, setMode] = useState<Mode>("list");
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const options = useMemo(() => moduleOptions(maven?.projects ?? [], gradle?.projects ?? []), [maven, gradle]);
const options = useMemo(() => moduleOptions(maven?.projects ?? [], gradle ?? []), [maven, gradle]);
const value = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null);
const gradlePath = value?.startsWith(GRADLE) ? value.slice(GRADLE.length) : null;
const selectedGradle = value?.startsWith(GRADLE)
? (gradle ?? [])
.flatMap((build) => build.projects.map((project) => ({ build, project })))
.find(({ build, project }) => gradleProjectKey(build, project) === value)
: null;
const gradlePath = selectedGradle?.project.path ?? null;
const pom = value != null && gradlePath == null ? value : null;
const module = pom ? (maven?.projects.find((p) => p.pom === pom) ?? null) : null;
const gradleProject = gradlePath != null ? (gradle?.projects.find((p) => p.path === gradlePath) ?? null) : null;
const report = useGradleTree(gradlePath, configuration, generation);
const gradleProject = selectedGradle?.project ?? null;
const report = useGradleTree(gradlePath, selectedGradle?.build.dir ?? null, configuration, generation);
const deps = useMemo(
() => (gradlePath != null ? (report.deps ?? []) : (module?.dependencies ?? [])),
[gradlePath, report.deps, module],
@@ -428,7 +436,7 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
showContextMenu({ x: e.clientX, y: e.clientY }, items);
};
if (!maven && !gradle) return <EmptyState title="No Maven or Gradle projects" />;
if (!maven && !gradle?.length) return <EmptyState title="No Maven or Gradle projects" />;
const treeView = (
<div className={styles.body}>
+10 -3
Переглянути файл
@@ -8,7 +8,7 @@ import { useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import type { BuildTool } from "@/platform/ipc";
import { HighlightedText, Input, List, Popup, Select, listNavigate, T } from "@/ui";
import { goalCompletions, taskCompletions, type Completion } from "./model";
import { goalCompletions, gradleProjectForKey, gradleProjectKey, taskCompletions, type Completion } from "./model";
import { executeBuildLine } from "./runAnything";
import { readHistory, useBuild } from "./store";
import styles from "./BuildTools.module.css";
@@ -32,7 +32,13 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
() =>
tool === "maven"
? (maven?.projects ?? []).map((p) => ({ value: p.pom, label: p.displayName, detail: p.dir || "." }))
: (gradle?.projects ?? []).map((p) => ({ value: p.path, label: p.name, detail: p.path })),
: (gradle ?? []).flatMap((build) =>
build.projects.map((p) => ({
value: gradleProjectKey(build, p),
label: p.name,
detail: `${build.dir || "."} ${p.path}`,
})),
),
[tool, maven, gradle],
);
const [project, setProject] = useState<string | null>(() =>
@@ -44,7 +50,8 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
const p = maven?.projects.find((x) => x.pom === project);
return goalCompletions(line, p?.plugins ?? [], history).slice(0, 200);
}
return gradle ? taskCompletions(line, gradle, history).slice(0, 200) : [];
const selected = gradleProjectForKey(gradle ?? [], project);
return selected ? taskCompletions(line, selected.build, history).slice(0, 200) : [];
}, [tool, maven, gradle, project, line, history]);
const close = () => useBuild.setState({ executeOpen: null });
+2 -2
Переглянути файл
@@ -36,7 +36,7 @@ const mavenModel: BuildModel = {
stale: true,
syncError: null,
},
gradle: null,
gradle: [],
};
describe("build tools feature", () => {
@@ -62,7 +62,7 @@ describe("build tools feature", () => {
expect(getContext()["build.maven"]).toBe(true);
expect(toolWindows()).not.toContain("gradle");
act(() => useBuild.setState({ model: { maven: null, gradle: null } }));
act(() => useBuild.setState({ model: { maven: null, gradle: [] } }));
expect(toolWindows()).not.toContain("maven");
expect(getContext()["build.maven"]).toBe(false);
});
+1 -1
Переглянути файл
@@ -28,7 +28,7 @@ vi.mock("@tauri-apps/api/core", async (original) => ({
const maven = (stale: boolean) =>
({ syncedAt: 1, stale, syncError: null, profiles: [], projects: [] }) as unknown as MavenModel;
const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: null });
const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: [] });
beforeEach(() => {
vi.clearAllMocks();
+83 -20
Переглянути файл
@@ -14,16 +14,20 @@ import {
registerOverlay,
registerRunAnythingProvider,
registerToolWindow,
toolWindowService,
} from "@/workbench/contributions";
import { lazyComponent } from "@/ui";
import { GradleView, MavenView } from "./BuildToolView";
import { ExecutePopupHost } from "./ExecutePopup";
import { buildToolsRunAnything } from "./runAnything";
import { versionCatalogExtension } from "./versionCatalog";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, gradleProjectKey } from "./model";
import {
ANALYZER_KIND,
addProjects,
linkGradleProjects,
unlinkGradleProjects,
requestSync,
unlinkProjects,
cancelSync,
loadModel,
@@ -33,6 +37,7 @@ import {
openAnalyzer,
resetBuild,
setExpanded,
setCurrentProject,
syncTool,
toggleGradleOffline,
toggleMavenOffline,
@@ -55,7 +60,7 @@ function expandAll(tool: BuildTool) {
tool,
expandableKeys(mavenRoots(m), (n) => mavenChildren(n, m, configs)),
);
} else if (tool === "gradle" && model?.gradle) {
} else if (tool === "gradle" && model?.gradle?.length) {
const g = model.gradle;
setExpanded(
tool,
@@ -84,24 +89,34 @@ function analyzerPom(arg: unknown): string | null {
/** The analyzer module of a Gradle project from an action argument: `{ gradlePath }`, or `{ path }` of its build script. */
function analyzerGradle(arg: unknown): string | null {
const { model, root } = useBuild.getState();
const a = (arg ?? {}) as { gradlePath?: unknown; path?: unknown };
if (typeof a.gradlePath === "string") return `gradle:${a.gradlePath}`;
const project =
typeof a.path === "string" && root
? model?.gradle?.projects.find((p) => p.buildFile && `${root}/${p.buildFile}` === a.path)
: undefined;
return project ? `gradle:${project.path}` : null;
const a = (arg ?? {}) as { buildDir?: unknown; gradlePath?: unknown; path?: unknown };
for (const build of model?.gradle ?? []) {
const project = build.projects.find((p) => {
if (typeof a.gradlePath === "string")
return p.path === a.gradlePath && (a.buildDir === undefined || a.buildDir === build.dir);
const file = p.buildFile;
return (
typeof a.path === "string" &&
root &&
file &&
(/^(?:[A-Za-z]:[\\/]|\/)/.test(file) ? file : `${root}/${file}`) === a.path
);
});
if (project) return gradleProjectKey(build, project);
}
return null;
}
const synced = new Set<string>();
/** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */
function apply(model: BuildModel | null, registered: Partial<Record<BuildTool, () => void>>) {
const has: Record<BuildTool, boolean> = { maven: !!model?.maven, gradle: !!model?.gradle };
const has: Record<BuildTool, boolean> = { maven: !!model?.maven, gradle: !!model?.gradle?.length };
setContext("build.maven", has.maven);
setContext("build.gradle", has.gradle);
for (const tool of ["maven", "gradle"] as const) {
if (has[tool] && !registered[tool]) {
const visible = has[tool] || (tool === "gradle" && model !== null && !!registered.gradle);
if (visible && !registered[tool]) {
registered[tool] =
tool === "maven"
? registerToolWindow({
@@ -120,25 +135,40 @@ function apply(model: BuildModel | null, registered: Partial<Record<BuildTool, (
order: 11,
component: GradleView,
});
} else if (!has[tool] && registered[tool]) {
} else if (!visible && registered[tool]) {
registered[tool]!();
delete registered[tool];
}
}
// IDEA imports a build when the project is first opened.
// Import each independent build once; linking another build must not be hidden by the first sync.
const root = useBuild.getState().root;
for (const tool of ["maven", "gradle"] as const) {
const info = tool === "maven" ? model?.maven : model?.gradle;
const key = `${tool}:${root}`;
if (info && (tool !== "maven" || model?.maven?.projects.length) && info.syncedAt == null && !synced.has(key)) {
const mavenKey = `maven:${root}`;
if (model?.maven?.projects.length && model.maven.syncedAt == null && !synced.has(mavenKey)) {
synced.add(mavenKey);
requestSync("maven");
}
for (const build of model?.gradle ?? []) {
const key = JSON.stringify([root, build.dir]);
if (build.syncedAt == null && !synced.has(key)) {
synced.add(key);
void syncTool(tool);
requestSync("gradle", build.dir);
}
}
}
export function activate(): () => void {
const registered: Partial<Record<BuildTool, () => void>> = {};
const showGradle = () => {
registered.gradle ??= registerToolWindow({
id: "gradle",
title: "Gradle",
icon: "toolwindow-gradle",
anchor: "right",
order: 11,
component: GradleView,
});
toolWindowService().show("gradle", true);
};
let timer: ReturnType<typeof setTimeout> | undefined;
const scheduleLoad = () => {
clearTimeout(timer);
@@ -187,6 +217,7 @@ export function activate(): () => void {
}),
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
synced.clear();
resetBuild();
if (s.project) void loadModel();
}),
@@ -207,6 +238,30 @@ export function activate(): () => void {
if (e.key === "languages" || e.key.startsWith("languages.java")) scheduleLoad();
}),
registerActions([
{
id: "gradle.linkProjects",
title: "Link Gradle Projects",
category: "Gradle",
icon: "plus",
when: "projectOpen && !workspaceOpen",
run: async () => {
showGradle();
await linkGradleProjects();
},
},
{
id: "gradle.unlinkProjects",
title: "Unlink Gradle Project",
category: "Gradle",
hidden: true,
when: `${GRADLE_WHEN} && !workspaceOpen`,
run: async ({ arg }) => {
const dir = (arg as { buildDir?: unknown } | undefined)?.buildDir;
const root = useBuild.getState().root;
if (await unlinkGradleProjects(typeof dir === "string" ? dir : null))
synced.delete(JSON.stringify([root, dir]));
},
},
{
id: "maven.addProjects",
title: "Add Maven Projects",
@@ -319,7 +374,10 @@ export function activate(): () => void {
icon: "refresh",
when: GRADLE_WHEN,
keywords: ["gradle", "sync", "refresh"],
run: () => syncTool("gradle"),
run: ({ arg }) => {
const dir = (arg as { buildDir?: unknown } | undefined)?.buildDir;
requestSync("gradle", typeof dir === "string" ? dir : null);
},
},
{
id: "gradle.analyzeDependencies",
@@ -348,7 +406,11 @@ export function activate(): () => void {
icon: "run-anything",
when: GRADLE_WHEN,
keywords: ["gradle", "task", "run anything"],
run: () => openExecute("gradle"),
run: ({ arg }) => {
const key = (arg as { projectKey?: unknown } | undefined)?.projectKey;
if (typeof key === "string") setCurrentProject("gradle", key);
openExecute("gradle");
},
},
{
id: "gradle.toggleOffline",
@@ -388,6 +450,7 @@ export function activate(): () => void {
for (const d of Object.values(registered)) d?.();
setContext("build.maven", false);
setContext("build.gradle", false);
synced.clear();
resetBuild();
};
}
+169 -4
Переглянути файл
@@ -1,11 +1,13 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { executeAction, registerAction, allActions, isActionEnabled } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, ProjectInfo } from "@/platform/ipc";
import type { BuildModel, GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { MavenView } from "./BuildToolView";
import { GradleView, MavenView } from "./BuildToolView";
import { activate } from "./index";
import { expandableKeys, gradleChildren, gradleRoots, gradleProjectKey } from "./model";
import { executeBuildLine } from "./runAnything";
import { useBuild } from "./store";
const mocks = vi.hoisted(() => ({
@@ -16,6 +18,10 @@ const mocks = vi.hoisted(() => ({
open: vi.fn(),
confirm: vi.fn(),
menu: vi.fn(),
link: vi.fn(),
unlink: vi.fn(),
show: vi.fn(),
sync: vi.fn(),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
@@ -29,9 +35,23 @@ vi.mock("@/platform/ipc", async (original) => {
settingsSet: mocks.set,
buildModel: mocks.model,
runConfigs: mocks.configs,
gradleLinkProjects: mocks.link,
gradleUnlinkProjects: mocks.unlink,
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;
},
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show: mocks.show }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm }));
vi.mock("@/ui", async (original) => ({
@@ -40,7 +60,7 @@ vi.mock("@/ui", async (original) => ({
}));
const model: BuildModel = {
gradle: null,
gradle: [],
maven: {
syncedAt: 1,
stale: false,
@@ -81,6 +101,7 @@ beforeEach(async () => {
mocks.confirm.mockResolvedValue(true);
useProject.setState({ project: { root: "/p" } as ProjectInfo });
setContext("projectOpen", true);
setContext("workspaceOpen", false);
await act(async () => {
dispose = activate();
});
@@ -204,3 +225,147 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/
expect(mocks.set).not.toHaveBeenCalled();
},
);
const gradleBuild = (dir: string): GradleModel => ({
dir,
command: `/p/${dir}/gradlew`,
env: { JAVA_HOME: `/${dir}/jdk` },
javaVersion: 21,
syncedAt: 1,
stale: false,
syncError: null,
projects: [
{ path: ":", name: dir, dir, buildFile: `${dir}/build.gradle`, tasks: [] },
{
path: ":app",
name: "app",
dir: `${dir}/app`,
buildFile: `${dir}/app/build.gradle`,
tasks: [{ name: "test", group: "verification", description: null }],
},
],
});
it("links from the toolbar and keeps Link available after unlinking the final build", async () => {
const build = gradleBuild("first");
const linked: BuildModel = { maven: null, gradle: [build] };
mocks.link.mockResolvedValue(linked);
mocks.unlink.mockResolvedValue({ maven: null, gradle: [] });
mocks.open.mockResolvedValue(["/p/first/settings.gradle"]);
act(() => useBuild.setState({ model: { maven: null, gradle: [] } }));
render(<GradleView />);
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Link Gradle Projects" })));
expect(mocks.link).toHaveBeenCalledWith(["/p/first/settings.gradle"]);
expect(screen.getByText("first")).toBeTruthy();
fireEvent.contextMenu(screen.getByText("first"));
expect(mocks.menu).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([expect.objectContaining({ action: "gradle.unlinkProjects", arg: { buildDir: "first" } })]),
);
await act(async () => executeAction("gradle.unlinkProjects", { buildDir: "first" }));
expect(mocks.unlink).toHaveBeenCalledWith(["first"]);
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Files will remain") }),
);
expect(screen.getByText("No Gradle projects")).toBeTruthy();
expect(screen.getByRole("button", { name: "Link Gradle Projects" })).toBeTruthy();
expect(useBuild.getState().model?.gradle).toEqual([]);
});
it("leaves links unchanged when unlink is cancelled or a module is targeted", async () => {
act(() => useBuild.setState({ model: { maven: null, gradle: [gradleBuild("first")] } }));
mocks.confirm.mockResolvedValue(false);
await executeAction("gradle.unlinkProjects", { buildDir: "first" });
await executeAction("gradle.unlinkProjects", { buildDir: "first/app" });
expect(mocks.unlink).not.toHaveBeenCalled();
expect(useBuild.getState().model?.gradle).toHaveLength(1);
});
it("runs duplicate task paths with the selected build's wrapper, cwd and offline setting", async () => {
const builds = [gradleBuild("first"), gradleBuild("second")];
const model: BuildModel = { maven: null, gradle: builds };
const roots = gradleRoots(builds);
const expanded = expandableKeys(roots, (n) => gradleChildren(n, builds, [], "/p"));
act(() =>
useBuild.setState({
model,
expanded: { maven: new Set(), gradle: expanded },
current: { gradle: gradleProjectKey(builds[1]!, builds[1]!.projects[1]!) },
settings: { ...useBuild.getState().settings, gradle: { offline: true } },
}),
);
const started: RunConfig[] = [];
const off = registerAction({
id: "run.runConfig",
title: "Run",
hidden: true,
run: ({ arg }) => {
started.push((arg as { config: RunConfig }).config);
},
});
render(<GradleView />);
const tasks = screen.getAllByText("test");
expect(tasks).toHaveLength(2);
await act(async () => fireEvent.doubleClick(tasks[1]!));
expect(executeBuildLine("gradle", "test")).toBe(true);
off();
expect(started).toHaveLength(2);
for (const config of started)
expect(config).toMatchObject({
command: "/p/second/gradlew",
cwd: "second",
args: [":app:test", "--offline"],
env: { JAVA_HOME: "/second/jdk" },
});
});
it("drops a late link result after the user changes projects", async () => {
let finish!: (model: BuildModel) => void;
mocks.open.mockResolvedValue("/p/first/build.gradle");
mocks.link.mockImplementation(
() =>
new Promise<BuildModel>((resolve) => {
finish = resolve;
}),
);
const pending = executeAction("gradle.linkProjects");
await vi.waitFor(() => expect(mocks.link).toHaveBeenCalled());
await act(async () => {
useProject.setState({ project: { root: "/other", trusted: false } as ProjectInfo });
finish({ maven: null, gradle: [gradleBuild("first")] });
await pending;
});
expect(useBuild.getState().model).toBeNull();
});
it("routes Reload to the chosen independent build", async () => {
const model: BuildModel = { maven: null, gradle: [gradleBuild("first"), gradleBuild("second")] };
mocks.sync.mockResolvedValue(model);
act(() => useBuild.setState({ model }));
await executeAction("gradle.reload", { buildDir: "second" });
await vi.waitFor(() =>
expect(mocks.sync).toHaveBeenCalledWith("gradle", expect.any(Number), false, [], "second", expect.anything()),
);
});
it("does not offer Link or Unlink for a native Workspace window", async () => {
act(() =>
useProject.setState({
project: { root: "/p", workspace: { version: 1, name: "workspace", projects: ["/p/first"] } } as ProjectInfo,
}),
);
setContext("workspaceOpen", true);
for (const id of ["gradle.linkProjects", "gradle.unlinkProjects"])
expect(isActionEnabled(allActions().find((a) => a.id === id)!)).toBe(false);
await executeAction("gradle.linkProjects");
expect(mocks.open).not.toHaveBeenCalled();
expect(mocks.link).not.toHaveBeenCalled();
});
it("does not link or start Gradle imports in an untrusted project", async () => {
act(() => useProject.setState({ project: { root: "/p", trusted: false } as ProjectInfo }));
await executeAction("gradle.linkProjects");
expect(mocks.open).not.toHaveBeenCalled();
expect(mocks.link).not.toHaveBeenCalled();
expect(mocks.sync).not.toHaveBeenCalled();
});
+63 -2
Переглянути файл
@@ -5,6 +5,8 @@ import {
goalCompletions,
gradleChildren,
gradleRoots,
gradleRunConfigs,
gradleProjectKey,
gradleTaskConfig,
isRunnable,
mavenChildren,
@@ -284,10 +286,10 @@ describe("Gradle tree and runs", () => {
syncError: null,
};
const kids = (n: BuildNode) =>
gradleChildren(n, gradle, [runConfig({ id: "g", name: "app [run]", kind: "gradle" })]) ?? [];
gradleChildren(n, [gradle], [runConfig({ id: "g", name: "app [run]", kind: "gradle" })]) ?? [];
it("nests subprojects and groups tasks, other last", () => {
const roots = gradleRoots(gradle);
const roots = gradleRoots([gradle]);
expect(labels(roots)).toEqual(["gradle-demo"]);
expect(labels(kids(roots[0]!))).toEqual(["Tasks", "Run Configurations", "app", "core"]);
const app = kids(roots[0]!)[2]!;
@@ -315,6 +317,65 @@ describe("Gradle tree and runs", () => {
]);
});
it("keeps identical project paths and tasks of independent builds distinct", () => {
const builds = ["first", "second"].map((dir) => ({
...gradle,
dir,
command: `/p/${dir}/gradlew`,
env: { JAVA_HOME: `/${dir}/jdk` },
projects: gradle.projects.map((p) => ({ ...p, dir: `${dir}/${p.dir}`.replace(/\/$/, "") })),
}));
const roots = gradleRoots(builds);
expect(new Set(roots.map((n) => n.key)).size).toBe(2);
const tasks = roots.map((r) => {
const app = gradleChildren(r, builds, [])!.find((n) => n.kind === "gradleProject" && n.project.path === ":app")!;
const folder = gradleChildren(app, builds, [])![0]!;
const group = gradleChildren(folder, builds, [])!.find(
(n) => n.kind === "taskGroup" && n.group === "verification",
)!;
return gradleChildren(group, builds, [])![0]!;
});
expect(tasks[0]!.key).not.toBe(tasks[1]!.key);
const runs = builds.map((b) => gradleTaskConfig(b, b.projects[1]!, ["test"], { offline: true }));
expect(runs[0]!.id).not.toBe(runs[1]!.id);
expect(runs[1]).toMatchObject({
command: "/p/second/gradlew",
cwd: "second",
env: { JAVA_HOME: "/second/jdk" },
args: [":app:test", "--offline"],
});
expect(gradleProjectKey(builds[1]!, builds[1]!.projects[1]!)).toBe('gradle:["second",":app"]');
});
it("shows run configurations only under their owning linked build", () => {
const nested = {
...gradle,
dir: "nested",
projects: gradle.projects.map((p) => ({ ...p, dir: `nested/${p.dir}` })),
};
const configs = [
runConfig({ id: "root", kind: "gradle", cwd: "app" }),
runConfig({ id: "nested", kind: "gradle", cwd: "nested/app" }),
runConfig({ id: "absolute", kind: "gradle", cwd: "/p/nested/app" }),
runConfig({ id: "other", kind: "maven", cwd: "nested" }),
];
expect(gradleRunConfigs(gradle, [gradle, nested], configs, "/p").map((c) => c.id)).toEqual(["root"]);
expect(gradleRunConfigs(nested, [gradle, nested], configs, "/p").map((c) => c.id)).toEqual(["nested", "absolute"]);
});
it("places a saved run under its directly linked root instead of a longer sibling composite", () => {
const composite = {
...gradle,
dir: "very-long-composite",
projects: [{ ...gradle.projects[1]!, path: ":b:app", dir: "b/app", build: ":b" }],
};
const direct = { ...gradle, dir: "b", projects: [{ ...gradle.projects[1]!, dir: "b/app" }] };
const saved = runConfig({ id: "saved", kind: "gradle", cwd: "b/app" });
const builds = [composite, direct];
expect(gradleRunConfigs(composite, builds, [saved], "/p")).toEqual([]);
expect(gradleRunConfigs(direct, builds, [saved], "/p")).toEqual([saved]);
});
it("completes task names and paths", () => {
const labelsOf = taskCompletions("ru", gradle).map((c) => c.label);
expect(labelsOf).toEqual([":app:run"]);
+61 -17
Переглянути файл
@@ -67,10 +67,10 @@ export type BuildNode =
| { kind: "dependency"; key: string; dependency: MavenDependency }
| { kind: "runConfigs"; key: string; configs: RunConfig[] }
| { kind: "runConfig"; key: string; config: RunConfig }
| { kind: "gradleProject"; key: string; project: GradleProject }
| { kind: "tasks"; key: string; project: GradleProject }
| { kind: "taskGroup"; key: string; project: GradleProject; group: string; tasks: GradleTask[] }
| { kind: "task"; key: string; project: GradleProject; task: GradleTask };
| { kind: "gradleProject"; key: string; build: GradleModel; project: GradleProject }
| { kind: "tasks"; key: string; build: GradleModel; project: GradleProject }
| { kind: "taskGroup"; key: string; build: GradleModel; project: GradleProject; group: string; tasks: GradleTask[] }
| { kind: "task"; key: string; build: GradleModel; project: GradleProject; task: GradleTask };
/** Options applied to every Maven run (toolbar toggles, Profiles checkboxes). */
export interface MavenRunOptions {
@@ -133,9 +133,28 @@ export function mavenRunConfigs(project: MavenProject, configs: readonly RunConf
return configs.filter((c) => c.kind === "maven" && normalizeDir(c.cwd) === project.dir);
}
/** Run configurations of a Gradle build: every `gradle` config. */
export function gradleRunConfigs(configs: readonly RunConfig[]): RunConfig[] {
return configs.filter((c) => c.kind === "gradle");
/** Run configurations under this build, with nested independent builds owning their own runs. */
export function gradleRunConfigs(
model: GradleModel,
models: readonly GradleModel[],
configs: readonly RunConfig[],
root: string,
): RunConfig[] {
const absolute = (path: string) =>
/^(?:[A-Za-z]:[\\/]|\/)/.test(path) ? normalizeDir(path) : normalizeDir(`${root}/${path}`);
return configs.filter((c) => {
if (c.kind !== "gradle") return false;
const cwd = absolute(c.cwd ?? "");
const contains = (dir: string) => cwd === dir || cwd.startsWith(`${dir}/`);
const ownerDepth = (build: GradleModel) => {
const dir = absolute(build.dir);
return contains(dir) ? dir.split("/").length : 0;
};
const owners = models
.filter((build) => [build.dir, ...build.projects.map((p) => p.dir)].some((dir) => contains(absolute(dir))))
.sort((a, b) => ownerDepth(b) - ownerDepth(a));
return owners[0]?.dir === model.dir;
});
}
function normalizeDir(cwd: string | null | undefined): string {
@@ -230,9 +249,26 @@ export function gradleTaskGroups(project: GradleProject): Array<{ group: string;
.map(([group, tasks]) => ({ group, tasks: [...tasks].sort((a, b) => a.name.localeCompare(b.name)) }));
}
export function gradleRoots(model: GradleModel): BuildNode[] {
const root = model.projects.find((p) => p.path === ":") ?? model.projects[0];
return root ? [{ kind: "gradleProject", key: `gradle:${root.path}`, project: root }] : [];
/** Identity of a project within one independently linked build. */
export const gradleProjectKey = (build: Pick<GradleModel, "dir">, project: Pick<GradleProject, "path">) =>
`gradle:${JSON.stringify([build.dir, project.path])}`;
export function gradleProjectForKey(models: readonly GradleModel[], key: string | null | undefined) {
for (const build of models) {
const project = build.projects.find((p) => gradleProjectKey(build, p) === key);
if (project) return { build, project };
}
if (key != null) return null;
const build = models[0];
const project = build?.projects[0];
return build && project ? { build, project } : null;
}
export function gradleRoots(models: readonly GradleModel[]): BuildNode[] {
return models.flatMap((build) => {
const root = build.projects.find((p) => p.path === ":") ?? build.projects[0];
return root ? [{ kind: "gradleProject" as const, key: gradleProjectKey(build, root), build, project: root }] : [];
});
}
/** Direct subprojects of `path` (`:a` and `:b` under `:`, `:a:x` under `:a`). */
@@ -248,31 +284,39 @@ export function gradleSubprojects(model: GradleModel, path: string): GradleProje
export function gradleChildren(
node: BuildNode,
model: GradleModel,
models: readonly GradleModel[],
configs: readonly RunConfig[],
root = "",
): BuildNode[] | undefined {
switch (node.kind) {
case "gradleProject": {
const p = node.project;
const out: BuildNode[] = [{ kind: "tasks", key: `${node.key}:tasks`, project: p }];
const out: BuildNode[] = [{ kind: "tasks", key: `${node.key}:tasks`, build: node.build, project: p }];
if (p.path === ":") {
const runs = gradleRunConfigs(configs);
const runs = gradleRunConfigs(node.build, models, configs, root);
if (runs.length) out.push({ kind: "runConfigs", key: `${node.key}:runs`, configs: runs });
}
for (const sub of gradleSubprojects(model, p.path))
out.push({ kind: "gradleProject", key: `gradle:${sub.path}`, project: sub });
for (const sub of gradleSubprojects(node.build, p.path))
out.push({ kind: "gradleProject", key: gradleProjectKey(node.build, sub), build: node.build, project: sub });
return out;
}
case "tasks":
return gradleTaskGroups(node.project).map(({ group, tasks }) => ({
kind: "taskGroup",
key: `${node.key}:${group}`,
build: node.build,
project: node.project,
group,
tasks,
}));
case "taskGroup":
return node.tasks.map((task) => ({ kind: "task", key: `${node.key}:${task.name}`, project: node.project, task }));
return node.tasks.map((task) => ({
kind: "task",
key: `${node.key}:${task.name}`,
build: node.build,
project: node.project,
task,
}));
case "runConfigs":
return node.configs.map((config) => ({ kind: "runConfig", key: `${node.key}:${config.id}`, config }));
default:
@@ -456,7 +500,7 @@ export function gradleTaskConfig(
if (options.offline && !args.includes("--offline")) args.push("--offline");
const names = tasks.filter((t) => !t.startsWith("-"));
return {
id: `gradle:task:${project.path}:${tasks.join(" ")}`,
id: `gradle:task:${JSON.stringify([model.dir, project.path])}:${tasks.join(" ")}`,
name: `${project.name} [${names.join(",")}]`,
kind: "gradle",
command: model.command,
+1 -1
Переглянути файл
@@ -37,7 +37,7 @@ const maven = {
],
projects: [],
} as unknown as MavenModel;
const model: BuildModel = { maven, gradle: null };
const model: BuildModel = { maven, gradle: [] };
/** The `-P` entries each sync was started with. */
const syncedProfiles = () => mocks.sync.mock.calls.map((c) => c[3] as string[]);
+17 -16
Переглянути файл
@@ -8,6 +8,7 @@ import type { RunAnythingItem, RunAnythingProvider } from "@/workbench/contribut
import {
goalCompletions,
gradleTaskConfig,
gradleProjectForKey,
mavenGoalConfig,
parseGoalLine,
parseTaskLine,
@@ -25,19 +26,20 @@ const WORD: Record<BuildTool, string> = { maven: "mvn", gradle: "gradle" };
export function lineTool(line: string): BuildTool | null {
const model = useBuild.getState().model;
if (model?.maven && COMMANDS.maven.test(line)) return "maven";
if (model?.gradle && COMMANDS.gradle.test(line)) return "gradle";
if (model?.gradle?.length && COMMANDS.gradle.test(line)) return "gradle";
return null;
}
/** The project a line runs in: the given one, else the tool window's current one, else the first. */
function projectFor(tool: BuildTool, key?: string | null) {
function mavenProjectFor(key?: string | null) {
const { model, current } = useBuild.getState();
if (tool === "maven") {
const projects = model?.maven?.projects ?? [];
return projects.find((p) => p.pom === (key ?? current.maven)) ?? projects[0];
}
const projects = model?.gradle?.projects ?? [];
return projects.find((p) => p.path === (key ?? current.gradle)) ?? projects[0];
const projects = model?.maven?.projects ?? [];
return projects.find((p) => p.pom === (key ?? current.maven)) ?? projects[0];
}
function gradleProjectFor(key?: string | null) {
const { model, current } = useBuild.getState();
return gradleProjectForKey(model?.gradle ?? [], key ?? current.gradle);
}
/** Runs goals / tasks typed for `tool` in a project; false when there is nothing to run. */
@@ -45,28 +47,27 @@ export function executeBuildLine(tool: BuildTool, text: string, projectKey?: str
const { model, settings } = useBuild.getState();
if (tool === "maven") {
const maven = model?.maven;
const project = projectFor("maven", projectKey);
const project = mavenProjectFor(projectKey);
const goals = parseGoalLine(text);
if (!maven || !project || !("pom" in project) || !goals.length) return false;
runConfig(mavenGoalConfig(project, goals, maven, settings.maven), "run");
} else {
const gradle = model?.gradle;
const project = projectFor("gradle", projectKey);
const selected = gradleProjectFor(projectKey);
const tasks = parseTaskLine(text);
if (!gradle || !project || "pom" in project || !tasks.length) return false;
runConfig(gradleTaskConfig(gradle, project, tasks, settings.gradle), "run");
if (!selected || !tasks.length) return false;
runConfig(gradleTaskConfig(selected.build, selected.project, tasks, settings.gradle), "run");
}
pushHistory(tool, text.replace(COMMANDS[tool], "").trim());
return true;
}
function completions(tool: BuildTool, rest: string) {
const model = useBuild.getState().model;
if (tool === "maven") {
const project = projectFor("maven");
const project = mavenProjectFor();
return goalCompletions(rest, project && "pom" in project ? project.plugins : [], readHistory("maven"));
}
return model?.gradle ? taskCompletions(rest, model.gradle, readHistory("gradle")) : [];
const selected = gradleProjectFor();
return selected ? taskCompletions(rest, selected.build, readHistory("gradle")) : [];
}
export const buildToolsRunAnything: RunAnythingProvider = {
+99 -25
Переглянути файл
@@ -27,7 +27,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 { profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
import { gradleRoots, profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
export interface BuildSettings {
maven: MavenRunOptions;
@@ -58,7 +58,7 @@ interface BuildState {
expanded: Record<BuildTool, Set<string>>;
/** Execute Goal / Task popup. */
executeOpen: BuildTool | null;
/** Project selected in each tree (Maven `pom`, Gradle path): where Execute Goal runs. */
/** Project selected in each tree (Maven `pom`, Gradle build/project key): where commands run. */
current: Partial<Record<BuildTool, string>>;
}
@@ -198,9 +198,6 @@ export function changedOutsideIde(paths: readonly string[], now = Date.now()): b
return external;
}
/** Root whose never-synced Gradle build was imported on open (once per open). */
let autoImported: string | null = null;
/** Re-reads the build model (and the run configurations) of the open project. */
export async function loadModel(): Promise<void> {
const root = useProject.getState().project?.root ?? null;
@@ -230,11 +227,6 @@ export async function loadModel(): Promise<void> {
const maven = model.maven;
if (maven?.stale && maven.syncedAt !== null && !useBuild.getState().syncing.maven) void syncTool("maven");
}
// Like IDEA, a Gradle build is imported when first opened; later changes wait for Reload.
if (model.gradle && model.gradle.syncedAt === null && autoImported !== root) {
autoImported = root;
void syncTool("gradle");
}
} catch (e) {
if (gen === generation) useBuild.setState({ loading: false, error: e instanceof Error ? e.message : String(e) });
}
@@ -291,30 +283,96 @@ export async function addProjects() {
await setLinked(root, pom, true);
}
/** Links independent Gradle builds without changing their files or build scripts. */
export async function linkGradleProjects() {
const gen = generation;
const root = useProject.getState().project?.root;
if (
!root ||
useProject.getState().project?.workspace ||
useProject.getState().project?.trusted === false ||
!isTauri()
)
return;
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: tx`Link Gradle Projects`,
directory: false,
multiple: true,
defaultPath: root,
filters: [{ name: "Gradle build files", extensions: ["gradle", "kts"] }],
});
const paths = typeof picked === "string" ? [picked] : picked;
if (!paths?.length || useProject.getState().project?.root !== root || gen !== generation) return;
const model = await call(commands.gradleLinkProjects(paths));
if (useProject.getState().project?.root !== root || gen !== generation) return;
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
if (useProject.getState().project?.root === root && gen === generation)
useBuild.setState((s) => ({
model,
configs,
expanded: {
...s.expanded,
gradle: new Set([...s.expanded.gradle, ...initialExpanded("gradle", model)]),
},
}));
}
export async function unlinkGradleProjects(buildDir: string | null) {
const expectedGeneration = generation;
const { root, model } = useBuild.getState();
const build = model?.gradle?.find((g) => g.dir === buildDir);
if (
!root ||
useProject.getState().project?.workspace ||
!build ||
!(await confirm({
title: "Unlink Gradle project?",
message:
"Remove this build from Gradle? Files will remain in the Project view; link its build file again to restore it.",
ok: "Unlink",
}))
)
return;
if (useProject.getState().project?.root !== root || expectedGeneration !== generation) return;
const gen = ++generation;
queuedSyncs.delete("gradle");
cancelSync("gradle");
const next = await call(commands.gradleUnlinkProjects([build.dir]));
if (useProject.getState().project?.root !== root || gen !== generation) return;
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
if (useProject.getState().project?.root !== root || gen !== generation) return;
useBuild.setState({ model: next, configs, current: { ...useBuild.getState().current, gradle: undefined } });
return true;
}
/** Tree keys expanded on first show: the top-level projects. */
export function initialExpanded(tool: BuildTool, model: BuildModel): Set<string> {
if (tool === "maven")
return new Set(model.maven?.projects.filter((p) => !p.aggregated).map((p) => `mvn:${p.pom}`) ?? []);
return new Set(model.gradle ? ["gradle::"] : []);
return new Set(gradleRoots(model.gradle ?? []).map((n) => n.key));
}
const TITLES: Record<BuildTool, string> = { maven: "Syncing Maven projects", gradle: "Syncing Gradle projects" };
/** Tools to sync again once their running sync ends: its options changed meanwhile. */
const queuedSyncs = new Set<BuildTool>();
const queuedSyncs = new Map<BuildTool, Set<string | null>>();
/** Syncs `tool` now, or right after the sync in progress (which used the previous options). */
export function requestSync(tool: BuildTool) {
if (useBuild.getState().syncing[tool]) queuedSyncs.add(tool);
else void syncTool(tool);
export function requestSync(tool: BuildTool, buildDir: string | null = null) {
if (useBuild.getState().syncing[tool]) {
const queued = queuedSyncs.get(tool) ?? new Set<string | null>();
queued.add(buildDir);
queuedSyncs.set(tool, queued);
} else void syncTool(tool, buildDir);
}
/** Reloads all projects of `tool` from the build tool itself (effective POM, dependency tree, task list). */
export async function syncTool(tool: BuildTool): Promise<void> {
/** Reloads the build tool model; `buildDir` limits a Gradle reload to one linked build. */
export async function syncTool(tool: BuildTool, buildDir: string | null = null): Promise<void> {
const state = useBuild.getState();
const gen = generation;
const root = state.root;
if (!root || !isTauri() || state.syncing[tool]) return;
if (!root || useProject.getState().project?.trusted === false || !isTauri() || state.syncing[tool]) return;
const request = nextRequestId();
const taskId = `build-sync:${tool}`;
const cancel = () => void commands.cancelRequest(request);
@@ -331,11 +389,18 @@ export async function syncTool(tool: BuildTool): Promise<void> {
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, channel));
const next = await call(commands.buildSync(tool, request, offline, profiles, buildDir, channel));
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;
useBuild.setState({ model: next, configs });
const error = tool === "maven" ? next.maven?.syncError : next.gradle?.syncError;
const error =
tool === "maven"
? next.maven?.syncError
: (next.gradle ?? [])
.map((g) => g.syncError)
.filter(Boolean)
.join("\n");
if (error)
notifications().notify({
group: "run",
@@ -355,13 +420,22 @@ export async function syncTool(tool: BuildTool): Promise<void> {
key: `build-sync-${tool}`,
});
} finally {
if (useBuild.getState().syncing[tool]?.request === request) setSync(undefined);
upsertTask({ id: taskId, title: TITLES[tool], message: null, fraction: 1, done: true });
if (queuedSyncs.delete(tool) && gen === generation) void syncTool(tool);
if (useBuild.getState().syncing[tool]?.request === request) {
setSync(undefined);
upsertTask({ id: taskId, title: TITLES[tool], message: null, fraction: 1, done: true });
const queued = queuedSyncs.get(tool);
const next = queued?.values().next();
if (next && !next.done && useBuild.getState().root === root) {
queued!.delete(next.value);
if (!queued!.size) queuedSyncs.delete(tool);
void syncTool(tool, next.value);
}
}
}
}
export function cancelSync(tool: BuildTool) {
queuedSyncs.delete(tool);
const s = useBuild.getState().syncing[tool];
if (!s) return;
const taskId = `build-sync:${tool}`;
@@ -419,8 +493,8 @@ export function setCurrentProject(tool: BuildTool, id: string | undefined) {
export const ANALYZER_KIND = "maven.dependencyAnalyzer";
/**
* Module the Dependency Analyzer shows: a Maven `pom` (project-relative) or `gradle:<path>`
* (`gradle::app`) for a Gradle project; `null` = the first one.
* Module the Dependency Analyzer shows: a Maven `pom` or a Gradle build/project key;
* `null` = the first one.
*/
export const useAnalyzer = create<{ module: string | null }>(() => ({ module: null }));
+7 -3
Переглянути файл
@@ -116,9 +116,13 @@ export function activate(): () => void {
const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
// The Build tool window's model (one load per build change), not a load of our own.
const build = useBuildModel.getState();
const gradleProjects = build.root === project?.root ? (build.model?.gradle?.projects ?? []) : [];
const rootName = gradleProjects.find((p) => p.path === ":")?.name ?? "";
const groups = own && project ? gradleModuleGroups(gradleProjects, rootName, project.root) : [];
const gradleBuilds = build.root === project?.root ? (build.model?.gradle ?? []) : [];
const groups =
own && project
? gradleBuilds.flatMap((g) =>
gradleModuleGroups(g.projects, g.projects.find((p) => p.path === ":")?.name ?? "", project.root, g.dir),
)
: [];
const key = JSON.stringify([paths, groups]);
if (key === shownRoots) return;
shownRoots = key;
+12
Переглянути файл
@@ -123,3 +123,15 @@ describe("module group nodes in the tree", () => {
expect(m.children.get(m.roots[0]!.key)!.map((n) => n.label)).toEqual(["billing", "core"]);
});
});
it("namespaces module group identities by independently linked build", () => {
const projects = [
{ path: ":", dir: "/w/first" },
{ path: ":app", dir: "/w/shared" },
];
const first = gradleModuleGroups(projects, "same", "/w/project", "first");
const second = gradleModuleGroups(projects, "same", "/w/project", "second");
expect(first.length).toBeGreaterThan(0);
expect(first[0]!.id).not.toBe(second[0]!.id);
expect(first.map(shape)).toEqual(second.map(shape));
});
+10 -2
Переглянути файл
@@ -29,7 +29,9 @@ export function gradleModuleGroups(
projects: readonly { path: string; dir: string; build?: string | null }[],
rootName: string,
projectRoot: string,
buildDir?: string,
): ExtraRoot[] {
const identity = (name: string) => (buildDir === undefined ? name : JSON.stringify([buildDir, name]));
const modules: Module[] = projects.map((p) => {
const path = p.path.split(":").filter(Boolean);
// An included build's modules are named after that build (`lib.core`); buildSrc and
@@ -51,7 +53,13 @@ export function gradleModuleGroups(
const moduleNode = (m: Module): ExtraRoot => {
const label = nameOf(m.dir) || m.dir;
return { id: m.name, label, detail: m.name === label ? undefined : m.name, icon: "gradle-module", path: m.dir };
return {
id: identity(m.name),
label,
detail: m.name === label ? undefined : m.name,
icon: "gradle-module",
path: m.dir,
};
};
// `modulesAndGroups`: ungrouped top-level modules as they are (the project folder is
// shown anyway), one node for the common group path of the grouped ones.
@@ -82,7 +90,7 @@ export function gradleModuleGroups(
const seen = new Set<string>();
const moduleNodes = members.filter((m) => !seen.has(m.dir) && !!seen.add(m.dir)).map(moduleNode);
return {
id: `gradleGroup:${group.join(".")}`,
id: identity(`gradleGroup:${group.join(".")}`),
label: group[group.length - 1]!,
icon: "gradle-project",
children: [...[...childGroups.values()].map(node).sort(byLabel), ...moduleNodes.sort(byLabel)],
+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,
+5 -1
Переглянути файл
@@ -103,6 +103,7 @@ export interface LanguagesValid {
jdtlsJdk: string;
jdtlsVmArgs: string;
gradle: {
linkedProjects: string[] | null;
jvm: string;
distribution: "wrapper" | "local";
home: string;
@@ -160,7 +161,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
jdk: "",
jdtlsJdk: "",
jdtlsVmArgs: "",
gradle: { jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" },
gradle: { linkedProjects: null, jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" },
maven: { home: "", userSettings: "", localRepository: "", unlinkedProjects: [], daemon: false, mvndHome: "" },
},
markdown: { previewFontSize: null },
@@ -292,6 +293,9 @@ export function validateLanguages(raw: unknown): LanguagesValid {
jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk),
jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs),
gradle: {
linkedProjects: Array.isArray(gradle.linkedProjects)
? gradle.linkedProjects.filter((p): p is string => typeof p === "string")
: null,
jvm: text(gradle.jvm, d.java.gradle.jvm),
distribution: oneOf(gradle.distribution, ["wrapper", "local"] as const, d.java.gradle.distribution),
home: text(gradle.home, d.java.gradle.home),
+5
Переглянути файл
@@ -1,4 +1,9 @@
{
"Link Gradle Projects": "关联 Gradle 项目",
"Unlink Gradle Project": "取消关联 Gradle 项目",
"Unlink Gradle project?": "取消关联 Gradle 项目?",
"Remove this build from Gradle? Files will remain in the Project view; link its build file again to restore it.": "从 Gradle 中移除此构建?文件将保留在项目视图中;重新关联其构建文件即可恢复。",
"Gradle build files": "Gradle 构建文件",
"Unity": "Unity 项目",
"Show Meta Files": "显示 Meta 文件",
"Show Generated Files": "显示生成的文件",
+30 -10
Переглянути файл
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { buildPresentation, isModuleFolder, moduleFolderIcon } from "./projectPresentation";
import type { BuildModel } from "./ipc";
const model = (partial: unknown) => ({ maven: null, gradle: null, ...(partial as object) }) as BuildModel;
const model = (partial: unknown) => ({ maven: null, gradle: [], ...(partial as object) }) as BuildModel;
describe("build presentation context", () => {
it.each(["maven", "gradle"] as const)("uses distinct %s root and nested module glyphs", (tool) => {
const modules = new Map([
@@ -45,12 +45,14 @@ describe("build presentation context", () => {
it("includes Gradle root and nested project directories", () => {
const context = buildPresentation(
model({
gradle: {
projects: [
{ dir: "", tasks: [] },
{ dir: "lib/core", tasks: [] },
],
},
gradle: [
{
projects: [
{ dir: "", tasks: [] },
{ dir: "lib/core", tasks: [] },
],
},
],
}),
);
expect([...context.modules]).toEqual([
@@ -67,9 +69,27 @@ describe("build presentation context", () => {
}),
).spring,
).toBe(true);
expect(buildPresentation(model({ gradle: { projects: [{ dir: "", tasks: [{ name: "bootRun" }] }] } })).spring).toBe(
true,
);
expect(
buildPresentation(model({ gradle: [{ projects: [{ dir: "", tasks: [{ name: "bootRun" }] }] }] })).spring,
).toBe(true);
});
it("keeps plain folders plain", () => expect(buildPresentation(model({})).modules.size).toBe(0));
});
it("includes modules and Spring tasks from every independently linked Gradle build", () => {
const context = buildPresentation(
model({
gradle: [
{
projects: [
{ dir: "first", tasks: [] },
{ dir: "first/app", tasks: [] },
],
},
{ projects: [{ dir: "second", tasks: [{ name: "bootRun" }] }] },
],
}),
);
expect([...context.modules.keys()]).toEqual(["first", "first/app", "second"]);
expect(context.spring).toBe(true);
});
+2 -2
Переглянути файл
@@ -14,13 +14,13 @@ export const useBuildPresentation = create<BuildPresentation>(() => EMPTY);
export function buildPresentation(model: BuildModel): BuildPresentation {
const modules = new Map<string, "maven" | "gradle">();
for (const p of model.maven?.projects ?? []) modules.set(p.dir, "maven");
for (const p of model.gradle?.projects ?? []) modules.set(p.dir, "gradle");
for (const p of (model.gradle ?? []).flatMap((g) => g.projects)) modules.set(p.dir, "gradle");
const spring =
(model.maven?.projects ?? []).some(
(p) =>
p.dependencies.some((d) => d.groupId.startsWith("org.springframework")) ||
p.plugins.some((p) => p.groupId.startsWith("org.springframework")),
) || (model.gradle?.projects ?? []).some((p) => p.tasks.some((t) => t.name === "bootRun"));
) || (model.gradle ?? []).some((g) => g.projects.some((p) => p.tasks.some((t) => t.name === "bootRun")));
return { modules, spring };
}