дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
682fee633d
коміт
0dfdbbc323
43 files changed
+1846
-254
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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)],
|
||||
|
||||
@@ -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])];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Заблокувати користувача