mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(gradle): analyze a Gradle project's dependencies per configuration (#1181)
The Dependency Analyzer lists Gradle projects next to Maven modules. For a Gradle project it runs `<project>:dependencies --configuration <name>` (compile/runtime/test classpaths) in the background (on the project host for remote projects), cancellable and cached per session, and reuses the list/tree views, search, conflicts-only filter and usage paths: an exact request Gradle replaced (1.0 -> 1.1) is a conflict showing the requested version, constraints are left out, unresolved dependencies are marked and build projects (quoted or not) are recognised. Exclude / Jump to Source stay Maven-only. Opened from the Gradle tool window toolbar, a Gradle project's context menu and a build script's menus. Closes #1005
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
//! A Gradle project's resolved dependencies for the Dependency Analyzer:
|
||||
//! `gradle <project>:dependencies --configuration <name>`, 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<String>,
|
||||
/// The version Gradle selected (`-> 1.2`), when it differs from the request.
|
||||
pub selected: Option<String>,
|
||||
/// 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<GradleDependency>,
|
||||
}
|
||||
|
||||
/// Parses the tree of a `dependencies --configuration` report (lines `+--- g:a:1.0 -> 1.1 (*)`).
|
||||
pub fn parse_report(output: &str) -> Vec<GradleDependency> {
|
||||
let mut roots: Vec<GradleDependency> = Vec::new();
|
||||
// Path of child indexes from the roots to the last node of each depth.
|
||||
let mut path: Vec<usize> = 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<Vec<GradleDependency>> {
|
||||
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<String> =
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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<Vec<ide_build::GradleDependency>> {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -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<Vec<ide_build::GradleDependency>> {
|
||||
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]
|
||||
|
||||
@@ -340,6 +340,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<SyncProgress>) => typedError<BuildModel, IdeError>(__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<GradleDependency[], IdeError>(__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 = {
|
||||
|
||||
@@ -154,7 +154,12 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
|
||||
action={`${tool}.toggleOffline`}
|
||||
pressed={maven ? settings.maven.offline : settings.gradle.offline}
|
||||
/>
|
||||
{maven && <IconButton icon="build-dependencies" size="small" iconSize={16} action="maven.analyzeDependencies" />}
|
||||
<IconButton
|
||||
icon="build-dependencies"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
action={maven ? "maven.analyzeDependencies" : "gradle.analyzeDependencies"}
|
||||
/>
|
||||
{maven && (
|
||||
<IconButton
|
||||
icon="skip-tests"
|
||||
@@ -331,6 +336,7 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
|
||||
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 } },
|
||||
);
|
||||
}
|
||||
if (node.kind === "dependency") {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
max-width: 260px;
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 200px;
|
||||
flex: 1 1 140px;
|
||||
min-width: 140px;
|
||||
max-width: 360px;
|
||||
}
|
||||
@@ -203,3 +203,10 @@
|
||||
.stepTarget {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
max-width: 560px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import type { BuildModel, MavenDependency } from "@/platform/ipc";
|
||||
import type { BuildModel, GradleDependency, GradleModel, MavenDependency } from "@/platform/ipc";
|
||||
import { menuGroupItems, provideEditorService, type EditorService } from "@/workbench/contributions";
|
||||
import { DependencyAnalyzer } from "./DependencyAnalyzer";
|
||||
import { activate } from "./index";
|
||||
import { ANALYZER_KIND, useAnalyzer, useBuild } from "./store";
|
||||
|
||||
const ipc = vi.hoisted(() => ({ 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<typeof import("@/platform/ipc")>();
|
||||
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(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
|
||||
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(<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();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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) {
|
||||
const root = useBuild((s) => s.root);
|
||||
const key = path && root ? `${root}|${path}|${configuration}` : null;
|
||||
const [state, setState] = useState<GradleTree & { key: string | null }>({
|
||||
key: null,
|
||||
deps: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
const served = useRef(generation);
|
||||
const running = useRef<number | null>(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 (
|
||||
<span className={styles.row}>
|
||||
<Icon name={artifact.conflict ? "warning" : "library"} size={16} className={styles.icon} />
|
||||
<Icon
|
||||
name={artifact.conflict ? "warning" : isBuildProject(artifact) ? "gradle-module" : "library"}
|
||||
size={16}
|
||||
className={styles.icon}
|
||||
/>
|
||||
<span className={clsx(styles.label, artifact.conflict && styles.conflict, selected && styles.selectedLabel)}>
|
||||
{artifact.key}
|
||||
</span>
|
||||
@@ -120,7 +206,11 @@ function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; confl
|
||||
const status = statusText(usageStatus(d));
|
||||
return (
|
||||
<span className={clsx(styles.row, d.omitted != null && styles.omitted)}>
|
||||
<Icon name={conflict ? "warning" : "library"} size={16} className={styles.icon} />
|
||||
<Icon
|
||||
name={conflict ? "warning" : isBuildProject(d) ? "gradle-module" : "library"}
|
||||
size={16}
|
||||
className={styles.icon}
|
||||
/>
|
||||
<span className={clsx(styles.label, conflict && styles.conflict, selected && styles.selectedLabel)}>
|
||||
{artifactKey(d)}
|
||||
</span>
|
||||
@@ -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 (
|
||||
<section className={styles.usage} aria-label={coordinates(usage.dependency)}>
|
||||
<div className={styles.usageHeader}>
|
||||
<Icon
|
||||
name={status.kind === "conflict" ? "warning" : status.kind === "resolved" ? "success" : "info"}
|
||||
name={
|
||||
status.kind === "conflict" ||
|
||||
status.kind === "unresolved" ||
|
||||
(status.kind === "resolved" && status.requested)
|
||||
? "warning"
|
||||
: status.kind === "resolved"
|
||||
? "success"
|
||||
: "info"
|
||||
}
|
||||
size={16}
|
||||
className={styles.icon}
|
||||
/>
|
||||
@@ -155,17 +264,19 @@ function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: Ma
|
||||
Exclude
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
icon="autoscroll-to-source"
|
||||
size="small"
|
||||
title="Jump to Source"
|
||||
onClick={() => void jumpToDeclaration(pom, direct)}
|
||||
/>
|
||||
{pom != null && (
|
||||
<IconButton
|
||||
icon="autoscroll-to-source"
|
||||
size="small"
|
||||
title="Jump to Source"
|
||||
onClick={() => void jumpToDeclaration(pom, direct)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ol className={styles.path}>
|
||||
<li className={styles.step} style={{ paddingLeft: 0 }}>
|
||||
<Icon name="maven-project" size={16} className={styles.icon} />
|
||||
<span className={styles.stepLabel}>{module.displayName}</span>
|
||||
<Icon name={moduleIcon} size={16} className={styles.icon} />
|
||||
<span className={styles.stepLabel}>{moduleName}</span>
|
||||
</li>
|
||||
{usage.path.map((d, i) => (
|
||||
<li key={i} className={styles.step} style={{ paddingLeft: (i + 1) * 16 }}>
|
||||
@@ -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 <EmptyState title="Select a dependency to see its usages" />;
|
||||
const usages = sortedUsages(artifact.usages);
|
||||
@@ -209,7 +322,7 @@ function UsagesPane({
|
||||
)}
|
||||
<div className={styles.usageList} aria-label={tx("Usages")}>
|
||||
{usages.map((u, i) => (
|
||||
<UsageBlock key={i} usage={u} module={module} pom={pom} />
|
||||
<UsageBlock key={i} usage={u} moduleName={moduleName} moduleIcon={moduleIcon} pom={pom} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<string>(ALL_SCOPES);
|
||||
const [configuration, setConfiguration] = useState("runtimeClasspath");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const [mode, setMode] = useState<Mode>("list");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(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<string> } | 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<string>, 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 <EmptyState title="No Maven projects" />;
|
||||
if (!maven && !gradle) return <EmptyState title="No Maven or Gradle projects" />;
|
||||
|
||||
const treeView = (
|
||||
<div className={styles.body}>
|
||||
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
|
||||
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
|
||||
<Tree<Node>
|
||||
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" ? (
|
||||
<ArtifactRow artifact={n.artifact} selected={state.selected} />
|
||||
) : (
|
||||
<TreeRow
|
||||
node={n.node}
|
||||
conflict={conflicted.has(artifactKey(n.node.dependency))}
|
||||
selected={state.selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onActivate={(n) => {
|
||||
const usage = usageOf(n);
|
||||
if (usage && pom) void jumpToDeclaration(pom, usage.path[0]!);
|
||||
}}
|
||||
onContextMenu={contextMenu}
|
||||
multiSelect={false}
|
||||
empty={<EmptyState title={artifacts.length ? "No dependencies match the filter" : "No dependencies"} />}
|
||||
/>
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="usages" minSize={240} className={styles.panel}>
|
||||
<UsagesPane
|
||||
artifact={selected}
|
||||
moduleName={module?.displayName ?? gradleProject?.name ?? ""}
|
||||
moduleIcon={pom ? "maven-project" : gradlePath === ":" ? "gradle-project" : "gradle-module"}
|
||||
pom={pom}
|
||||
/>
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
@@ -306,10 +483,10 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
|
||||
<Select
|
||||
aria-label="Module"
|
||||
className={styles.module}
|
||||
value={pom}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={(v) => {
|
||||
useAnalyzer.setState({ pom: v });
|
||||
useAnalyzer.setState({ module: v });
|
||||
setSelectedKey(null);
|
||||
}}
|
||||
/>
|
||||
@@ -322,7 +499,26 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
|
||||
clearable
|
||||
/>
|
||||
<Checkbox checked={conflictsOnly} onChange={setConflictsOnly} label="Show conflicts only" />
|
||||
<Select aria-label="Scope" className={styles.scope} value={scope} options={scopeOptions} onChange={setScope} />
|
||||
{gradlePath != null ? (
|
||||
<Select
|
||||
aria-label="Configuration"
|
||||
className={styles.scope}
|
||||
value={configuration}
|
||||
options={configurationOptions}
|
||||
onChange={(c) => {
|
||||
setConfiguration(c);
|
||||
setSelectedKey(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
aria-label="Scope"
|
||||
className={styles.scope}
|
||||
value={scope}
|
||||
options={scopeOptions}
|
||||
onChange={setScope}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.sep} />
|
||||
<IconButton
|
||||
icon="list"
|
||||
@@ -341,79 +537,100 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
|
||||
onClick={() => setMode("tree")}
|
||||
/>
|
||||
<span className={styles.sep} />
|
||||
{syncing ? (
|
||||
{gradlePath != null ? (
|
||||
report.loading ? (
|
||||
<IconButton icon="stop" size="small" iconSize={16} title="Stop" onClick={report.cancel} />
|
||||
) : (
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title="Refresh"
|
||||
onClick={() => setGeneration((g) => g + 1)}
|
||||
/>
|
||||
)
|
||||
) : syncing ? (
|
||||
<IconButton icon="stop" size="small" iconSize={16} action="maven.cancelReload" />
|
||||
) : (
|
||||
<IconButton icon="refresh" size="small" iconSize={16} action="maven.reload" />
|
||||
)}
|
||||
</div>
|
||||
{syncing ? (
|
||||
<div className={styles.banner} role="status">
|
||||
<Spinner size={12} />
|
||||
<span className={styles.bannerText}>{syncing.message}</span>
|
||||
</div>
|
||||
) : maven.stale && maven.syncedAt != null ? (
|
||||
<div className={styles.banner}>
|
||||
<Icon name="maven-load-changes" size={16} />
|
||||
<span className={styles.bannerText}>
|
||||
<T>Dependency trees may be out of date</T>
|
||||
</span>
|
||||
<Button size="small" variant="ghost" onClick={() => void syncTool("maven")}>
|
||||
Load Maven Changes
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{maven.syncedAt == null ? (
|
||||
<EmptyState
|
||||
title="Dependency trees are read when Maven projects are reloaded"
|
||||
actions={
|
||||
<Button size="small" onClick={() => void syncTool("maven")} disabled={!!syncing}>
|
||||
Reload All Maven Projects
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : !module || !pom ? (
|
||||
{gradlePath != null ? (
|
||||
<>
|
||||
{report.loading ? (
|
||||
<div className={styles.banner} role="status">
|
||||
<Spinner size={12} />
|
||||
<span className={styles.bannerText}>
|
||||
<T>Resolving dependencies…</T>
|
||||
</span>
|
||||
</div>
|
||||
) : report.error && report.deps ? (
|
||||
<div className={styles.banner} role="alert" title={report.error}>
|
||||
<Icon name="warning" size={16} />
|
||||
<span className={styles.bannerText}>
|
||||
<T>Cannot resolve dependencies</T>: {report.error}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{report.error && !report.deps ? (
|
||||
<EmptyState
|
||||
title="Cannot resolve dependencies"
|
||||
actions={
|
||||
<Button size="small" onClick={() => setGeneration((g) => g + 1)}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<span className={styles.errorText}>{report.error}</span>
|
||||
</EmptyState>
|
||||
) : report.deps ? (
|
||||
treeView
|
||||
) : report.loading ? null : (
|
||||
<EmptyState
|
||||
title="Stopped"
|
||||
actions={
|
||||
<Button size="small" onClick={() => setGeneration((g) => g + 1)}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : !maven ? (
|
||||
<EmptyState title="No Maven modules" />
|
||||
) : (
|
||||
<div className={styles.body}>
|
||||
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
|
||||
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
|
||||
<Tree<Node>
|
||||
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" ? (
|
||||
<ArtifactRow artifact={n.artifact} selected={state.selected} />
|
||||
) : (
|
||||
<TreeRow
|
||||
node={n.node}
|
||||
conflict={conflicted.has(artifactKey(n.node.dependency))}
|
||||
selected={state.selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onActivate={(n) => {
|
||||
const usage = usageOf(n);
|
||||
if (usage) void jumpToDeclaration(pom, usage.path[0]!);
|
||||
}}
|
||||
onContextMenu={contextMenu}
|
||||
multiSelect={false}
|
||||
empty={<EmptyState title={artifacts.length ? "No dependencies match the filter" : "No dependencies"} />}
|
||||
/>
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="usages" minSize={240} className={styles.panel}>
|
||||
<UsagesPane artifact={selected} module={module} pom={pom} />
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
</div>
|
||||
<>
|
||||
{syncing ? (
|
||||
<div className={styles.banner} role="status">
|
||||
<Spinner size={12} />
|
||||
<span className={styles.bannerText}>{syncing.message}</span>
|
||||
</div>
|
||||
) : maven.stale && maven.syncedAt != null ? (
|
||||
<div className={styles.banner}>
|
||||
<Icon name="maven-load-changes" size={16} />
|
||||
<span className={styles.bannerText}>
|
||||
<T>Dependency trees may be out of date</T>
|
||||
</span>
|
||||
<Button size="small" variant="ghost" onClick={() => void syncTool("maven")}>
|
||||
Load Maven Changes
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{maven.syncedAt == null ? (
|
||||
<EmptyState
|
||||
title="Dependency trees are read when Maven projects are reloaded"
|
||||
actions={
|
||||
<Button size="small" onClick={() => void syncTool("maven")} disabled={!!syncing}>
|
||||
Reload All Maven Projects
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : !module || !pom ? (
|
||||
<EmptyState title="No Maven modules" />
|
||||
) : (
|
||||
treeView
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<MavenDependency, "groupId" | "artifactId">) =>
|
||||
!d.groupId && d.artifactId.startsWith(":");
|
||||
|
||||
export const artifactKey = (d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier">) =>
|
||||
[d.groupId, d.artifactId, d.classifier].filter(Boolean).join(":");
|
||||
|
||||
@@ -44,10 +49,50 @@ export function coordinates(d: Pick<MavenDependency, "groupId" | "artifactId" |
|
||||
return [d.groupId, d.artifactId, d.classifier, d.version].filter(Boolean).join(":");
|
||||
}
|
||||
|
||||
/**
|
||||
* A Gradle occurrence in the analyzer's terms: `version` is the one Gradle selected,
|
||||
* `requested` the one this path asked for when conflict resolution replaced it.
|
||||
*/
|
||||
type GradleOccurrence = MavenDependency & { requested?: string | null; unresolved?: boolean };
|
||||
|
||||
const isResolved = (d: MavenDependency) => d.omitted == null;
|
||||
|
||||
/** The version a usage asked for: before `<dependencyManagement>` replaced it, if it did. */
|
||||
export const requestedVersion = (d: MavenDependency) => d.managedFrom ?? d.version ?? null;
|
||||
/** The version a usage asked for: before `<dependencyManagement>` (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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
/** 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",
|
||||
|
||||
@@ -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:<path>`
|
||||
* (`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 });
|
||||
}
|
||||
|
||||
@@ -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 浅色",
|
||||
|
||||
Reference in New Issue
Block a user