diff --git a/crates/ide-build/src/gradle_deps.rs b/crates/ide-build/src/gradle_deps.rs new file mode 100644 index 00000000..40c90202 --- /dev/null +++ b/crates/ide-build/src/gradle_deps.rs @@ -0,0 +1,257 @@ +//! A Gradle project's resolved dependencies for the Dependency Analyzer: +//! `gradle :dependencies --configuration `, parsed from its tree report. + +use crate::gradle; +use crate::process; +use crate::sync::{SyncOptions, error_summary}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; + +/// One line of the report: an occurrence of a dependency where it is requested. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GradleDependency { + /// `group`, empty for a project dependency. + pub group: String, + /// Artifact name, or the project path (`:core`) of a project dependency. + pub name: String, + /// The version asked for here; `None` when a platform or constraint supplies it. + pub requested: Option, + /// The version Gradle selected (`-> 1.2`), when it differs from the request. + pub selected: Option, + /// A project of the build (`project :core`, or a module substituted by one). + pub project: bool, + /// `(*)`: listed with its dependencies earlier in the tree. + pub repeated: bool, + /// `(c)`: a dependency constraint rather than a dependency. + pub constraint: bool, + /// `(n)` / `FAILED`: not resolved. + pub unresolved: bool, + pub children: Vec, +} + +/// Parses the tree of a `dependencies --configuration` report (lines `+--- g:a:1.0 -> 1.1 (*)`). +pub fn parse_report(output: &str) -> Vec { + let mut roots: Vec = Vec::new(); + // Path of child indexes from the roots to the last node of each depth. + let mut path: Vec = Vec::new(); + for line in output.lines() { + let Some((depth, text)) = tree_line(line) else { continue }; + let node = parse_entry(text); + path.truncate(depth); + if depth > path.len() { + continue; // A line deeper than its parent allows: not part of a well-formed tree. + } + let siblings = if depth == 0 { + &mut roots + } else { + let mut list = &mut roots; + for &i in &path[..depth - 1] { + list = &mut list[i].children; + } + let Some(parent) = list.get_mut(path[depth - 1]) else { continue }; + &mut parent.children + }; + siblings.push(node); + path.push(siblings.len() - 1); + } + roots +} + +/// `| \--- g:a:1.0` → (1, "g:a:1.0"). +fn tree_line(line: &str) -> Option<(usize, &str)> { + let mut rest = line; + let mut depth = 0; + loop { + if let Some(r) = rest.strip_prefix("+--- ").or_else(|| rest.strip_prefix("\\--- ")) { + return Some((depth, r.trim())); + } + rest = rest.strip_prefix("| ").or_else(|| rest.strip_prefix(" "))?; + depth += 1; + } +} + +fn parse_entry(text: &str) -> GradleDependency { + let mut d = GradleDependency::default(); + let mut text = text.trim(); + loop { + if let Some(t) = text.strip_suffix(" (*)") { + d.repeated = true; + text = t; + } else if let Some(t) = text.strip_suffix(" (c)") { + d.constraint = true; + text = t; + } else if let Some(t) = text.strip_suffix(" (n)").or_else(|| text.strip_suffix(" FAILED")) { + d.unresolved = true; + text = t; + } else { + break; + } + text = text.trim_end(); + } + let (left, right) = match text.split_once(" -> ") { + Some((l, r)) => (l.trim(), Some(r.trim())), + None => (text, None), + }; + if let Some(project) = left.strip_prefix("project ") { + d.project = true; + d.name = project_path(project); + } else { + let mut parts = left.splitn(3, ':'); + d.group = parts.next().unwrap_or_default().to_owned(); + d.name = parts.next().unwrap_or_default().to_owned(); + d.requested = parts.next().filter(|v| !v.is_empty()).map(str::to_owned); + } + if let Some(right) = right { + if let Some(project) = right.strip_prefix("project ") { + // A module substituted by a project of the build. + d.project = true; + d.selected = Some(project_path(project)); + } else { + d.selected = Some(right.to_owned()); + } + } + d +} + +/// `:core`, or `':core'` as Gradle 9 quotes it. +fn project_path(text: &str) -> String { + let t = text.trim(); + t.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')).unwrap_or(t).to_owned() +} + +/// Runs the `dependencies` report of `project` (a Gradle path, `:` for the root) for +/// `configuration` and returns its tree. +pub fn gradle_dependencies( + root: &Path, + options: &SyncOptions, + project: &str, + configuration: &str, + cancel: &CancellationToken, +) -> IdeResult> { + if !configuration.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') || configuration.is_empty() { + return Err(IdeError::invalid(format!("not a configuration name: {configuration}"))); + } + if !project.starts_with(':') || project.chars().any(char::is_whitespace) { + return Err(IdeError::invalid(format!("not a Gradle project path: {project}"))); + } + let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?; + let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") }; + let mut args: Vec = + vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()]; + if options.offline { + args.push("--offline".into()); + } + let out = process::run( + options.gradle_command.as_deref().unwrap_or(&model.command), + &args, + &root.join(&model.dir), + &options.env, + cancel, + |_, _| {}, + )?; + if !out.success { + return Err(IdeError::unavailable(error_summary(&out.text))); + } + Ok(parse_report(&out.text)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dep(group: &str, name: &str, requested: Option<&str>) -> GradleDependency { + GradleDependency { + group: group.into(), + name: name.into(), + requested: requested.map(Into::into), + ..Default::default() + } + } + + #[test] + fn parses_the_tree_with_selected_versions_and_markers() { + let report = "\ +------------------------------------------------------------ +Project ':app' +------------------------------------------------------------ + +runtimeClasspath - Runtime classpath of source set 'main'. ++--- org.springframework.boot:spring-boot-starter-web -> 3.2.0 +| +--- org.springframework.boot:spring-boot-starter:3.2.0 +| | \\--- org.yaml:snakeyaml:2.2 +| \\--- com.fasterxml.jackson.core:jackson-databind:2.15.0 -> 2.15.3 (*) ++--- project ':core' +| \\--- com.google.guava:guava:{strictly 32.1.3-jre} -> 32.1.3-jre (c) ++--- org.example:legacy:1.0 -> project :legacy +\\--- org.missing:gone:9 FAILED + +(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree. +(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation. +"; + let tree = parse_report(report); + assert_eq!(tree.len(), 4); + let web = &tree[0]; + assert_eq!((web.group.as_str(), web.name.as_str()), ("org.springframework.boot", "spring-boot-starter-web")); + assert_eq!((web.requested.as_deref(), web.selected.as_deref()), (None, Some("3.2.0"))); + assert_eq!(web.children.len(), 2); + assert_eq!(web.children[0].children, vec![dep("org.yaml", "snakeyaml", Some("2.2"))]); + let databind = &web.children[1]; + assert_eq!((databind.requested.as_deref(), databind.selected.as_deref()), (Some("2.15.0"), Some("2.15.3"))); + assert!(databind.repeated); + let core = &tree[1]; + assert!(core.project && core.group.is_empty()); + assert_eq!(core.name, ":core"); + let guava = &core.children[0]; + assert!(guava.constraint); + assert_eq!(guava.requested.as_deref(), Some("{strictly 32.1.3-jre}")); + let legacy = &tree[2]; + assert!(legacy.project); + assert_eq!((legacy.name.as_str(), legacy.selected.as_deref()), ("legacy", Some(":legacy"))); + assert!(tree[3].unresolved); + assert_eq!(tree[3].requested.as_deref(), Some("9")); + } + + #[test] + fn an_empty_configuration_has_no_dependencies() { + assert!( + parse_report("compileClasspath - Compile classpath for source set 'main'.\nNo dependencies\n").is_empty() + ); + } + + #[test] + fn rejects_names_that_are_not_configurations_or_projects() { + let tmp = tempfile::tempdir().unwrap(); + let options = SyncOptions::default(); + let cancel = CancellationToken::new(); + assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err()); + assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err()); + } + + #[cfg(unix)] + #[test] + fn runs_the_report_of_the_project_and_configuration() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("settings.gradle"), "include 'app'").unwrap(); + std::fs::write(root.join("build.gradle"), "").unwrap(); + let launcher = root.join("fake-gradle"); + std::fs::write(&launcher, "#!/bin/sh\necho \"$@\" > args\necho '\\--- org.a:b:1.0'\n").unwrap(); + std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap(); + let options = SyncOptions { + gradle_command: Some(launcher.to_string_lossy().into_owned()), + offline: true, + ..Default::default() + }; + let tree = + gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap(); + assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]); + assert_eq!( + std::fs::read_to_string(root.join("args")).unwrap().trim(), + "--console=plain -q :app:dependencies --configuration testRuntimeClasspath --offline" + ); + } +} diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 7ca4789c..ab0588c4 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -8,6 +8,7 @@ pub mod application_update; pub mod compile; pub mod gradle; +mod gradle_deps; pub mod kotlin_bridge; pub mod maven; pub mod pom_edit; @@ -17,6 +18,7 @@ pub mod web; mod xml; pub use gradle::{GradleModel, GradleProject, GradleTask}; +pub use gradle_deps::{GradleDependency, gradle_dependencies}; pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission}; pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven}; diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 472411a3..15337380 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -238,6 +238,36 @@ pub async fn gradle_published_catalog( .map_err(join_err) } +/// The resolved dependency tree of the Gradle project `gradle_path` (`:`, `:app`) for +/// `configuration`, from Gradle's `dependencies` report; cancellable with `cancel_request(request)`. +pub async fn gradle_dependencies( + host: &Host, + window: &str, + gradle_path: String, + configuration: String, + offline: bool, + request: RequestId, +) -> IdeResult> { + host.require_trust(window)?; + let project = host.project(window)?; + let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); + let token = host.begin_project_request(window, request); + let result = tokio::task::spawn_blocking(move || { + let gradle = load(&root, &cache, &settings).gradle; + let options = SyncOptions { + offline, + env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(), + gradle_command: gradle.as_ref().map(|g| g.command.clone()), + ..Default::default() + }; + ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token) + }) + .await + .map_err(join_err); + host.end_request(request); + result? +} + /// Where the direct dependency `dependency` of the Maven module `pom` is declared. pub async fn maven_dependency_declaration( host: &Host, @@ -367,6 +397,17 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("gradle_published_catalog", |c| async move { gradle_published_catalog(&c.host, &c.window, c.arg("coordinates")?).await }); + d.add("gradle_dependencies", |c| async move { + gradle_dependencies( + &c.host, + &c.window, + c.arg("gradlePath")?, + c.arg("configuration")?, + c.arg("offline")?, + c.arg("request")?, + ) + .await + }); d.add("maven_dependency_declaration", |c| async move { maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await }); diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 7daaf11e..c55634b7 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -43,6 +43,21 @@ pub async fn build_sync( .await } +/// The resolved dependency tree of a Gradle project for one configuration (Dependency Analyzer). +#[tauri::command] +#[specta::specta] +pub async fn gradle_dependencies( + window: Window, + state: State<'_, AppState>, + gradle_path: String, + configuration: String, + offline: bool, + request: RequestId, +) -> IdeResult> { + ide_host::ops::build::gradle_dependencies(&state.host, window.label(), gradle_path, configuration, offline, request) + .await +} + /// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a /// build has downloaded it. #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a9fd89c3..2c59878d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -340,6 +340,7 @@ pub fn builder() -> tauri_specta::Builder { run::run_test_reports, build::build_model, build::build_sync, + build::gradle_dependencies, build::gradle_published_catalog, build::maven_dependency_declaration, build::maven_exclude_dependency, diff --git a/src/bindings.ts b/src/bindings.ts index a88a23b1..59e38349 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -647,6 +647,8 @@ export const commands = { * Returns the refreshed model; a failed build is reported in its `syncError`. */ buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel) => typedError(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })), + /** The resolved dependency tree of a Gradle project for one configuration (Dependency Analyzer). */ + gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId) => typedError(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request })), /** * The `.toml` of a version catalog the build imports as `group:artifact:version`, once a * build has downloaded it. @@ -3713,6 +3715,27 @@ export type GoSettings = { export type GotoKind = "definition" | "declaration" | "typeDefinition" | "implementation"; +/** One line of the report: an occurrence of a dependency where it is requested. */ +export type GradleDependency = { + /** `group`, empty for a project dependency. */ + group: string, + /** Artifact name, or the project path (`:core`) of a project dependency. */ + name: string, + /** The version asked for here; `None` when a platform or constraint supplies it. */ + requested: string | null, + /** The version Gradle selected (`-> 1.2`), when it differs from the request. */ + selected: string | null, + /** A project of the build (`project :core`, or a module substituted by one). */ + project: boolean, + /** `(*)`: listed with its dependencies earlier in the tree. */ + repeated: boolean, + /** `(c)`: a dependency constraint rather than a dependency. */ + constraint: boolean, + /** `(n)` / `FAILED`: not resolved. */ + unresolved: boolean, + children: GradleDependency[], +}; + export type GradleDistribution = "wrapper" | "local"; export type GradleModel = { diff --git a/src/features/build-tools/BuildToolView.tsx b/src/features/build-tools/BuildToolView.tsx index fe4f9697..3680ba41 100644 --- a/src/features/build-tools/BuildToolView.tsx +++ b/src/features/build-tools/BuildToolView.tsx @@ -154,7 +154,12 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[] action={`${tool}.toggleOffline`} pressed={maven ? settings.maven.offline : settings.gradle.offline} /> - {maven && } + {maven && ( ({ exclude: vi.fn(), declaration: vi.fn() })); +const ipc = vi.hoisted(() => ({ exclude: vi.fn(), declaration: vi.fn(), gradle: vi.fn(), cancel: vi.fn() })); vi.mock("@/platform/ipc", async (original) => { const real = await original(); return { ...real, call: (value: unknown) => value, - commands: { ...real.commands, mavenExcludeDependency: ipc.exclude, mavenDependencyDeclaration: ipc.declaration }, + commands: { + ...real.commands, + mavenExcludeDependency: ipc.exclude, + mavenDependencyDeclaration: ipc.declaration, + gradleDependencies: ipc.gradle, + cancelRequest: ipc.cancel, + }, }; }); @@ -88,7 +94,7 @@ describe("dependency analyzer", () => { it("opens as an editor tab from the action and from a pom.xml's menus", async () => { await executeAction("maven.analyzeDependencies", { pom: "pom.xml" }); expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: ANALYZER_KIND }), { focus: true }); - expect(useAnalyzer.getState().pom).toBe("pom.xml"); + expect(useAnalyzer.getState().module).toBe("pom.xml"); for (const menu of ["editor", "projectView", "editorTab"] as const) { const items = menuGroupItems(menu, { paths: ["/p/pom.xml"] }); expect(items[0]).toMatchObject({ action: "maven.analyzeDependencies", arg: { pom: "pom.xml" } }); @@ -116,3 +122,96 @@ describe("dependency analyzer", () => { ); }); }); + +function gradleDep(coords: string, selected: string | null, children: GradleDependency[] = []): GradleDependency { + const [group, name, requested] = coords.split(":"); + return { + group: group!, + name: name!, + requested: requested ?? null, + selected, + project: false, + repeated: false, + constraint: false, + unresolved: false, + children, + }; +} + +const gradle: GradleModel = { + dir: "", + projects: [ + { path: ":", name: "demo", dir: "", buildFile: "build.gradle", tasks: [] }, + { path: ":app", name: "app", dir: "app", buildFile: "app/build.gradle.kts", tasks: [] }, + ], + javaVersion: null, + command: "gradle", + env: {}, + syncedAt: null, + stale: false, + syncError: null, +}; + +describe("dependency analyzer for Gradle", () => { + let dispose: () => void = () => {}; + const openInput = vi.fn(() => Promise.resolve()); + beforeEach(() => { + provideEditorService({ openInput, activePath: () => null } as unknown as EditorService); + setContext("projectOpen", true); + dispose = activate(); + act(() => useBuild.setState({ root: "/g", model: { maven: null, gradle } })); + }); + afterEach(() => { + dispose(); + setContext("projectOpen", false); + vi.clearAllMocks(); + }); + + 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"); + 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(menuGroupItems("editor", { paths: ["/g/other/build.gradle"] })).toEqual([]); + }); + + it("reads the configuration's report and marks versions Gradle replaced", async () => { + ipc.gradle.mockResolvedValue([ + gradleDep("org.springframework.boot:spring-boot-starter-json", "3.2.0", [ + gradleDep("com.fasterxml.jackson.core:jackson-databind:2.15.0", "2.15.3"), + ]), + gradleDep("com.fasterxml.jackson.core:jackson-databind:2.15.3", null), + ]); + act(() => useAnalyzer.setState({ module: "gradle::app" })); + render(); + 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(); + fireEvent.mouseDown(screen.getByText("com.fasterxml.jackson.core:jackson-databind").closest("[role=treeitem]")!, { + button: 0, + }); + expect(screen.getByText("requested 2.15.0")).toBeTruthy(); + expect(screen.getByText("org.springframework.boot:spring-boot-starter-json:3.2.0")).toBeTruthy(); + // Exclude and Jump to Source edit a pom.xml: not offered for Gradle. + expect(screen.queryByRole("button", { name: "Exclude" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull(); + }); + + 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::" })); + render(); + expect(await screen.findByText("Cannot resolve dependencies")).toBeTruthy(); + expect(screen.getByText(/Could not resolve all files/)).toBeTruthy(); + ipc.gradle.mockResolvedValue([]); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Retry" }))); + expect(ipc.gradle).toHaveBeenCalledTimes(2); + expect(await screen.findByText("No dependencies")).toBeTruthy(); + }); +}); diff --git a/src/features/build-tools/DependencyAnalyzer.tsx b/src/features/build-tools/DependencyAnalyzer.tsx index 89c7b91c..7fb71f98 100644 --- a/src/features/build-tools/DependencyAnalyzer.tsx +++ b/src/features/build-tools/DependencyAnalyzer.tsx @@ -1,11 +1,20 @@ /** - * Dependency Analyzer editor tab: a Maven module's resolved artifacts (flat list or - * the transitive tree) with conflicts marked, and for the selected artifact every - * path that brings it in, with Exclude / Jump to Source / Copy Coordinates. + * Dependency Analyzer editor tab: a Maven module's or a Gradle project's resolved + * artifacts (flat list or the transitive tree) with conflicts marked, and for the + * selected artifact every path that brings it in, with Exclude / Jump to Source (Maven) + * and Copy Coordinates. Gradle trees are read per configuration, on demand. */ -import { useMemo, useState, type MouseEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import clsx from "clsx"; -import { call, commands, type MavenDependency, type MavenProject } from "@/platform/ipc"; +import { + call, + commands, + isCancelled, + nextRequestId, + type GradleProject, + type MavenDependency, + type MavenProject, +} from "@/platform/ipc"; import { useTx, useTxTemplate, tx } from "@/platform/i18n"; import { editorService, notifications, type EditorInput } from "@/workbench/contributions"; import { @@ -34,6 +43,8 @@ import { exclusionFor, filterArtifacts, filterTree, + fromGradle, + isBuildProject, scopesOf, sortedUsages, statusText, @@ -51,6 +62,66 @@ type Node = | { kind: "node"; key: string; node: DependencyTreeNode; children: Node[] }; const ALL_SCOPES = "*"; +const GRADLE = "gradle:"; +/** The resolvable configurations of the Java plugins. */ +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. */ +const gradleTrees = new Map(); + +/** The `dependencies` report of `path` for `configuration`, cached; a new `generation` runs Gradle again. */ +function useGradleTree(path: string | null, configuration: string, generation: number) { + const root = useBuild((s) => s.root); + const key = path && root ? `${root}|${path}|${configuration}` : null; + const [state, setState] = useState({ + key: null, + deps: null, + error: null, + loading: false, + }); + const served = useRef(generation); + const running = useRef(null); + useEffect(() => { + if (!key || !path) return; + const forced = served.current !== generation; + served.current = generation; + const cached = gradleTrees.get(key); + if (cached && !forced) { + setState({ key, deps: cached, error: null, loading: false }); + return; + } + const request = nextRequestId(); + running.current = request; + // Answers of a run this effect no longer owns (a cancelled one included) are dropped. + let live = true; + 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)) + .then((deps) => { + const tree = fromGradle(deps); + gradleTrees.set(key, tree); + if (live) setState({ key, deps: tree, error: null, loading: false }); + }) + .catch((e) => { + if (live) setState({ key, deps: cached ?? null, error: isCancelled(e) ? null : errorText(e), loading: false }); + }) + .finally(() => { + done = true; + if (running.current === request) running.current = null; + }); + return () => { + live = false; + if (!done) void commands.cancelRequest(request).catch(() => {}); + }; + }, [key, path, 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(() => {}); + }; + return { ...current, cancel }; +} function errorText(e: unknown) { return e instanceof Error ? e.message : String(e); @@ -91,17 +162,32 @@ function versionLabel(d: MavenDependency) { return d.version ?? ""; } -function moduleOptions(projects: readonly MavenProject[]): SelectOption[] { - return projects - .filter((p) => p.packaging !== "pom" || p.dependencies.length > 0) - .map((p) => ({ value: p.pom, label: p.displayName, detail: p.pom, icon: "maven-project" })); +function moduleOptions(maven: readonly MavenProject[], gradle: readonly GradleProject[]): 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", + })), + ]; } function ArtifactRow({ artifact, selected }: { artifact: AnalyzedArtifact; selected: boolean }) { const tx = useTx(); return ( - + {artifact.key} @@ -120,7 +206,11 @@ function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; confl const status = statusText(usageStatus(d)); return ( - + {artifactKey(d)} @@ -131,17 +221,36 @@ function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; confl ); } -function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: MavenProject; pom: string }) { +function UsageBlock({ + usage, + moduleName, + moduleIcon, + pom, +}: { + usage: DependencyUsage; + moduleName: string; + moduleIcon: string; + /** The Maven module's pom; `null` for Gradle (no Exclude / Jump to Source). */ + pom: string | null; +}) { const tx = useTxTemplate(); const status = usageStatus(usage.dependency); const text = statusText(status); const direct = usage.path[0]!; - const canExclude = !!exclusionFor(usage); + const canExclude = pom != null && !!exclusionFor(usage); return (
@@ -155,17 +264,19 @@ function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: Ma Exclude )} - void jumpToDeclaration(pom, direct)} - /> + {pom != null && ( + void jumpToDeclaration(pom, direct)} + /> + )}
  1. - - {module.displayName} + + {moduleName}
  2. {usage.path.map((d, i) => (
  3. @@ -183,12 +294,14 @@ function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: Ma function UsagesPane({ artifact, - module, + moduleName, + moduleIcon, pom, }: { artifact: AnalyzedArtifact | null; - module: MavenProject; - pom: string; + moduleName: string; + moduleIcon: string; + pom: string | null; }) { if (!artifact) return ; const usages = sortedUsages(artifact.usages); @@ -209,7 +322,7 @@ function UsagesPane({ )}
    {usages.map((u, i) => ( - + ))}
    @@ -219,25 +332,36 @@ 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 syncing = useBuild((s) => s.syncing.maven); - const chosen = useAnalyzer((s) => s.pom); + const chosen = useAnalyzer((s) => s.module); const [query, setQuery] = useState(""); const [conflictsOnly, setConflictsOnly] = useState(false); const [scope, setScope] = useState(ALL_SCOPES); + const [configuration, setConfiguration] = useState("runtimeClasspath"); + const [generation, setGeneration] = useState(0); const [mode, setMode] = useState("list"); const [selectedKey, setSelectedKey] = useState(null); - const options = useMemo(() => moduleOptions(maven?.projects ?? []), [maven]); - const pom = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null); - const module = maven?.projects.find((p) => p.pom === pom) ?? null; - const artifacts = useMemo(() => analyze(module?.dependencies ?? []), [module]); + const options = useMemo(() => moduleOptions(maven?.projects ?? [], gradle?.projects ?? []), [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 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 deps = useMemo( + () => (gradlePath != null ? (report.deps ?? []) : (module?.dependencies ?? [])), + [gradlePath, report.deps, module], + ); + const artifacts = useMemo(() => analyze(deps), [deps]); const conflicted = useMemo(() => new Set(artifacts.filter((a) => a.conflict).map((a) => a.key)), [artifacts]); const filter = useMemo( - () => ({ query, conflictsOnly, scope: scope === ALL_SCOPES ? null : scope }), - [query, conflictsOnly, scope], + () => ({ query, conflictsOnly, scope: scope === ALL_SCOPES || gradlePath != null ? null : scope }), + [query, conflictsOnly, scope, gradlePath], ); const listed = useMemo(() => filterArtifacts(artifacts, filter), [artifacts, filter]); - const tree = useMemo(() => filterTree(module?.dependencies ?? [], filter, conflicted), [module, filter, conflicted]); + const tree = useMemo(() => filterTree(deps, filter, conflicted), [deps, filter, conflicted]); const roots: Node[] = useMemo(() => { const toNode = (node: DependencyTreeNode): Node => ({ kind: "node", @@ -251,7 +375,7 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) { }, [mode, listed, tree]); // A narrowing filter opens the tree down to its matches until the user expands or collapses. - const filterId = `${mode}|${filter.query}|${filter.conflictsOnly}|${filter.scope}|${pom}`; + const filterId = `${mode}|${filter.query}|${filter.conflictsOnly}|${filter.scope}|${value}|${configuration}`; const [manual, setManual] = useState<{ id: string; keys: Set } | null>(null); const expanded = useMemo(() => { if (manual?.id === filterId) return manual.keys; @@ -274,6 +398,7 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) { { value: ALL_SCOPES, label: tx("All scopes") }, ...scopesOf(artifacts).map((s) => ({ value: s, label: s })), ]; + const configurationOptions: SelectOption[] = CONFIGURATIONS.map((c) => ({ value: c, label: c })); const nodeArtifactKey = (n: Node) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency)); /** The usage a row stands for: a tree node's own, else the artifact's direct (or first) one. */ @@ -286,19 +411,71 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) { }; const contextMenu = (n: Node | null, _sel: Set, e: MouseEvent) => { - if (!n || !pom) return; + if (!n || !value) return; e.preventDefault(); const usage = usageOf(n); if (!usage) return; - const items: MenuItem[] = [ - { label: "Jump to Source", icon: "autoscroll-to-source", run: () => void jumpToDeclaration(pom, usage.path[0]!) }, - ]; - if (exclusionFor(usage)) items.push({ label: "Exclude", run: () => void exclude(pom, usage) }); + const items: MenuItem[] = []; + if (pom) { + items.push({ + label: "Jump to Source", + icon: "autoscroll-to-source", + run: () => void jumpToDeclaration(pom, usage.path[0]!), + }); + if (exclusionFor(usage)) items.push({ label: "Exclude", run: () => void exclude(pom, usage) }); + } items.push({ label: "Copy Coordinates", icon: "copy", run: () => void copyText(coordinates(usage.dependency)) }); showContextMenu({ x: e.clientX, y: e.clientY }, items); }; - if (!maven) return ; + if (!maven && !gradle) return ; + + const treeView = ( +
    + + + + aria-label="Dependencies" + roots={roots} + getKey={(n) => n.key} + getLabel={(n) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency))} + getChildren={(n) => (n.kind === "node" ? n.children : undefined)} + isExpandable={(n) => n.kind === "node" && n.node.children.length > 0} + expanded={expanded} + onExpandedChange={(next) => setManual({ id: filterId, keys: next })} + onSelectionChange={(_keys, lead) => setSelectedKey(lead ? nodeArtifactKey(lead) : null)} + renderNode={(n, state) => + n.kind === "artifact" ? ( + + ) : ( + + ) + } + onActivate={(n) => { + const usage = usageOf(n); + if (usage && pom) void jumpToDeclaration(pom, usage.path[0]!); + }} + onContextMenu={contextMenu} + multiSelect={false} + empty={} + /> + + + + + + +
    + ); return (
    @@ -306,10 +483,10 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) { + {gradlePath != null ? ( + + )} setMode("tree")} /> - {syncing ? ( + {gradlePath != null ? ( + report.loading ? ( + + ) : ( + setGeneration((g) => g + 1)} + /> + ) + ) : syncing ? ( ) : ( )}
    - {syncing ? ( -
    - - {syncing.message} -
    - ) : maven.stale && maven.syncedAt != null ? ( -
    - - - Dependency trees may be out of date - - -
    - ) : null} - {maven.syncedAt == null ? ( - void syncTool("maven")} disabled={!!syncing}> - Reload All Maven Projects - - } - /> - ) : !module || !pom ? ( + {gradlePath != null ? ( + <> + {report.loading ? ( +
    + + + Resolving dependencies… + +
    + ) : report.error && report.deps ? ( +
    + + + Cannot resolve dependencies: {report.error} + +
    + ) : null} + {report.error && !report.deps ? ( + setGeneration((g) => g + 1)}> + Retry + + } + > + {report.error} + + ) : report.deps ? ( + treeView + ) : report.loading ? null : ( + setGeneration((g) => g + 1)}> + Retry + + } + /> + )} + + ) : !maven ? ( ) : ( -
    - - - - aria-label="Dependencies" - roots={roots} - getKey={(n) => n.key} - getLabel={(n) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency))} - getChildren={(n) => (n.kind === "node" ? n.children : undefined)} - isExpandable={(n) => n.kind === "node" && n.node.children.length > 0} - expanded={expanded} - onExpandedChange={(next) => setManual({ id: filterId, keys: next })} - onSelectionChange={(_keys, lead) => setSelectedKey(lead ? nodeArtifactKey(lead) : null)} - renderNode={(n, state) => - n.kind === "artifact" ? ( - - ) : ( - - ) - } - onActivate={(n) => { - const usage = usageOf(n); - if (usage) void jumpToDeclaration(pom, usage.path[0]!); - }} - onContextMenu={contextMenu} - multiSelect={false} - empty={} - /> - - - - - - -
    + <> + {syncing ? ( +
    + + {syncing.message} +
    + ) : maven.stale && maven.syncedAt != null ? ( +
    + + + Dependency trees may be out of date + + +
    + ) : null} + {maven.syncedAt == null ? ( + void syncTool("maven")} disabled={!!syncing}> + Reload All Maven Projects + + } + /> + ) : !module || !pom ? ( + + ) : ( + treeView + )} + )} ); diff --git a/src/features/build-tools/analyzer.test.ts b/src/features/build-tools/analyzer.test.ts index db281189..8e3fde03 100644 --- a/src/features/build-tools/analyzer.test.ts +++ b/src/features/build-tools/analyzer.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { MavenDependency } from "@/platform/ipc"; +import type { GradleDependency, MavenDependency } from "@/platform/ipc"; import { analyze, coordinates, exclusionFor, filterArtifacts, filterTree, + fromGradle, scopesOf, sortedUsages, statusText, @@ -122,3 +123,47 @@ describe("dependency analyzer", () => { expect(coordinates(dep("g:a:1"))).toBe("g:a:1"); }); }); + +describe("Gradle reports", () => { + const g = (coords: string, extra: Partial = {}): GradleDependency => { + const [group, name, requested] = coords.split(":"); + return { + group: group!, + name: name!, + requested: requested ?? null, + selected: null, + project: false, + repeated: false, + constraint: false, + unresolved: false, + children: [], + ...extra, + }; + }; + + it("keeps dependencies, drops constraints and marks only exact requests that lost as conflicts", () => { + const deps = fromGradle([ + g("a:replaced:1.0", { selected: "1.1" }), + g("a:strict:{strictly 2.0}", { selected: "2.0" }), + g("a:dynamic:1.+", { selected: "1.5" }), + g("a:platform", { selected: "3.0" }), + g("a:constraint:4.0", { constraint: true }), + g("a:legacy:1.0", { selected: ":legacy", project: true }), + g("a:missing:9", { unresolved: true }), + g("x:y", { group: "", name: ":core", project: true }), + ]); + const artifacts = analyze(deps); + expect(artifacts.map((a) => [a.key, a.version, a.conflict])).toEqual([ + [":core", "", false], + ["a:dynamic", "1.5", false], + ["a:legacy", "project :legacy", false], + ["a:missing", "9", false], + ["a:platform", "3.0", false], + ["a:replaced", "1.1", true], + ["a:strict", "2.0", false], + ]); + expect(artifacts.find((a) => a.key === "a:replaced")!.requested).toEqual(["1.0"]); + expect(statusText(usageStatus(deps[0]!))).toBe("requested 1.0"); + expect(statusText(usageStatus(deps[5]!))).toBe("not resolved"); + }); +}); diff --git a/src/features/build-tools/analyzer.ts b/src/features/build-tools/analyzer.ts index 2f82006d..f38a5250 100644 --- a/src/features/build-tools/analyzer.ts +++ b/src/features/build-tools/analyzer.ts @@ -1,9 +1,10 @@ /** * Dependency Analyzer model: the artifacts of a Maven module's dependency tree * (read with `dependency:tree -Dverbose`, so it also lists the occurrences Maven - * omitted), which of them conflict, and every path that brings them in. + * omitted) or of a Gradle configuration (`dependencies` report), which of them + * conflict, and every path that brings them in. */ -import type { ArtifactKey, MavenDependency } from "@/platform/ipc"; +import type { ArtifactKey, GradleDependency, MavenDependency } from "@/platform/ipc"; /** One occurrence of an artifact in the tree, with the path from a direct dependency down to it. */ export interface DependencyUsage { @@ -36,6 +37,10 @@ export interface AnalyzerFilter { scope: string | null; } +/** A Gradle project of the build (`project :core`): no group, its path as the name. */ +export const isBuildProject = (d: Pick) => + !d.groupId && d.artifactId.startsWith(":"); + export const artifactKey = (d: Pick) => [d.groupId, d.artifactId, d.classifier].filter(Boolean).join(":"); @@ -44,10 +49,50 @@ export function coordinates(d: Pick d.omitted == null; -/** The version a usage asked for: before `` replaced it, if it did. */ -export const requestedVersion = (d: MavenDependency) => d.managedFrom ?? d.version ?? null; +/** The version a usage asked for: before `` (or Gradle) replaced it, if it did. */ +export const requestedVersion = (d: MavenDependency) => + (d as GradleOccurrence).requested ?? d.managedFrom ?? d.version ?? null; + +/** `{strictly 1.0}` / `{require 1.0}` / `{prefer 1.0}` → `1.0`. */ +const plainVersion = (v: string) => v.replace(/^\{(?:strictly|require|prefer) ([^}]*)\}$/, "$1"); + +/** A version that names one release (not a range or dynamic version). */ +const isExact = (v: string) => !/[[\](),+{}]|latest\./.test(v); + +/** + * A Gradle configuration's report as analyzer dependencies. Constraints (`(c)`) are left + * out: they are not dependencies. A path whose exact request lost to another version + * (`1.0 -> 1.1`) is a conflict. + */ +export function fromGradle(deps: readonly GradleDependency[]): MavenDependency[] { + return deps + .filter((d) => !d.constraint) + .map((d): GradleOccurrence => { + const requested = d.requested ? plainVersion(d.requested) : null; + // A project of the build has no version (`""`, not `null`: it isn't omitted). + const version = d.project ? (d.selected ? `project ${d.selected}` : "") : (d.selected ?? requested); + return { + groupId: d.group, + artifactId: d.name, + version, + type: "jar", + classifier: null, + scope: "compile", + optional: false, + children: fromGradle(d.children), + requested: !d.project && requested && version && requested !== version && isExact(requested) ? requested : null, + unresolved: d.unresolved, + }; + }); +} /** Every artifact of `deps` (a module's dependency tree), sorted by key. */ export function analyze(deps: readonly MavenDependency[]): AnalyzedArtifact[] { @@ -78,7 +123,7 @@ export function analyze(deps: readonly MavenDependency[]): AnalyzedArtifact[] { } const requested = requestedVersion(d); if (requested && !a.requested.includes(requested)) a.requested.push(requested); - if (d.omitted === "conflict") a.conflict = true; + if (d.omitted === "conflict" || (d as GradleOccurrence).requested) a.conflict = true; walk(d.children, path); } }; @@ -138,13 +183,16 @@ export function filterTree( } export type UsageStatus = - | { kind: "resolved"; version: string | null; managedFrom: string | null } + | { kind: "resolved"; version: string | null; managedFrom: string | null; requested: string | null } + | { kind: "unresolved"; version: string | null } | { kind: "conflict"; version: string | null; winner: string | null } | { kind: "duplicate"; version: string | null } | { kind: "cycle"; version: string | null }; export function usageStatus(d: MavenDependency): UsageStatus { const version = d.version ?? null; + const gradle = d as GradleOccurrence; + if (gradle.unresolved) return { kind: "unresolved", version }; switch (d.omitted) { case "conflict": return { kind: "conflict", version, winner: d.conflictWith ?? null }; @@ -153,7 +201,7 @@ export function usageStatus(d: MavenDependency): UsageStatus { case "cycle": return { kind: "cycle", version }; default: - return { kind: "resolved", version, managedFrom: d.managedFrom ?? null }; + return { kind: "resolved", version, managedFrom: d.managedFrom ?? null, requested: gradle.requested ?? null }; } } @@ -166,7 +214,10 @@ export function statusText(s: UsageStatus): string | null { return "omitted for duplicate"; case "cycle": return "omitted for cycle"; + case "unresolved": + return "not resolved"; default: + if (s.requested) return `requested ${s.requested}`; return s.managedFrom ? `version managed from ${s.managedFrom}` : null; } } diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index aab16b65..a6707986 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -81,6 +81,18 @@ function analyzerPom(arg: unknown): string | null { return current.maven ?? 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 synced = new Set(); /** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */ @@ -143,6 +155,19 @@ export function activate(): () => void { icon: () => "build-dependencies", restorable: true, }), + // A Gradle build script in the editor, its tab and the project view: that project's dependencies. + registerMenuGroup({ + id: "gradle.analyzeDependencies", + menus: ["editor", "editorTab", "projectView"], + order: 30, + items: (_menu, { paths, dirs }) => { + const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null; + if (!path || !/\/build\.gradle(\.kts)?$/.test(path) || !analyzerGradle({ path })) return []; + return [ + { kind: "action", action: "gradle.analyzeDependencies", arg: { path }, label: "Analyze Dependencies…" }, + ]; + }, + }), // pom.xml in the editor, its tab and the project view: Analyze Dependencies of that module. registerMenuGroup({ id: "maven.analyzeDependencies", @@ -296,6 +321,17 @@ export function activate(): () => void { keywords: ["gradle", "sync", "refresh"], run: () => syncTool("gradle"), }, + { + id: "gradle.analyzeDependencies", + title: "Analyze Dependencies…", + description: + "Shows a Gradle project's resolved dependencies per configuration, version conflicts and where each library comes from", + category: "Gradle", + icon: "build-dependencies", + when: GRADLE_WHEN, + keywords: ["gradle", "dependency", "dependencies", "conflict", "tree", "configuration", "analyzer"], + run: ({ arg }) => openAnalyzer(analyzerGradle(arg)), + }, { id: "gradle.cancelReload", title: "Stop Reloading Gradle Projects", diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 6afd30dc..09df0d84 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -374,7 +374,7 @@ export function resetBuild() { clearTimeout(profileReload); profileReload = undefined; queuedSyncs.clear(); - useAnalyzer.setState({ pom: null }); + useAnalyzer.setState({ module: null }); for (const tool of ["maven", "gradle"] as const) { cancelSync(tool); removeTask(`build-sync:${tool}`); @@ -418,11 +418,14 @@ export function setCurrentProject(tool: BuildTool, id: string | undefined) { export const ANALYZER_KIND = "maven.dependencyAnalyzer"; -/** Module the Dependency Analyzer shows (`pom`, project-relative); `null` = the first one. */ -export const useAnalyzer = create<{ pom: string | null }>(() => ({ pom: null })); +/** + * Module the Dependency Analyzer shows: a Maven `pom` (project-relative) or `gradle:` + * (`gradle::app`) for a Gradle project; `null` = the first one. + */ +export const useAnalyzer = create<{ module: string | null }>(() => ({ module: null })); -/** Opens (or focuses) the Dependency Analyzer tab, showing the module `pom` when given. */ -export async function openAnalyzer(pom: string | null) { - if (pom) useAnalyzer.setState({ pom }); +/** Opens (or focuses) the Dependency Analyzer tab, showing `module` when given. */ +export async function openAnalyzer(module: string | null) { + if (module) useAnalyzer.setState({ module }); await editorService().openInput({ kind: ANALYZER_KIND, key: ANALYZER_KIND, data: {} }, { focus: true }); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 25991ee9..6b1cf43b 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3922,6 +3922,12 @@ "omitted for duplicate": "因重复而省略", "omitted for cycle": "因循环依赖而省略", "version managed from {version}": "版本由 {version} 经依赖管理调整", + "No Maven or Gradle projects": "没有 Maven 或 Gradle 项目", + "Resolving dependencies…": "正在解析依赖…", + "Cannot resolve dependencies": "无法解析依赖", + "requested {version}": "请求的版本为 {version}", + "not resolved": "未解析", + "Shows a Gradle project's resolved dependencies per configuration, version conflicts and where each library comes from": "按配置显示 Gradle 项目解析后的依赖、版本冲突以及每个库的来源", "Cannot find the declaration": "找不到声明", "Cannot exclude {name}": "无法排除 {name}", "Solarized Light": "Solarized 浅色",