feat(gradle): import modules from Gradle itself (includeFlat, included builds, computed includes) (#715)

The Gradle model came from a static parse of settings.gradle, which cannot follow includeFlat, projectDir assignments, computed include lists or included builds, so those modules were missing from the Gradle tool window, the Project view, indexing and run. Sync now runs an init script that prints every evaluated project (identity path, directory, build directory, source sets with test classification, Java version) without resolving dependencies; the static parser stays as the fallback before the first sync. Modules outside the root become content roots, the Project view lists the build's module group above the project folder, an unsynced Gradle build is imported once when opened, and staleness ignores comments and whitespace in build scripts.

Application / Spring Boot configurations of a Gradle module run through a JavaExec task on the source set's runtime classpath by default (Settings › Build Tools › Gradle › Build and run using), with the debugger attached to the forked JVM. The build model reaches run and the Project view through a platform store rather than a cross-feature import.

Fixes #737
Fixes #861

Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com>
This commit is contained in:
ntbang0901
2026-11-04 01:09:07 +08:00
committed by GitHub
parent 0c1a64bc2c
commit bd424a3805
34 changed files with 1454 additions and 110 deletions
+40
View File
@@ -0,0 +1,40 @@
// OpenIDEA Gradle sync: prints each project as Gradle evaluated it, one JSON line per project.
// Read by `ide_build::gradle::parse_reported_projects`. Dependencies are not resolved: the
// Java language server reads the classpath itself, and resolving is the slow part of an import.
import groovy.json.JsonOutput
def canon = { File f -> f.canonicalPath }
// Init scripts also run in included builds and buildSrc, whose projects reuse `:`, `:core`…:
// the identity path (`:lib:core`, `:buildSrc`) tells them apart and addresses their tasks
// from the root build. Internal API, hence the fallback.
def identity = { Project p -> try { p.identityPath.toString() } catch (ignored) { p.path } }
def describe = { Project p ->
def m = [path: identity(p), dir: canon(p.projectDir), buildDir: canon(p.layout.buildDirectory.get().asFile)]
if (p.gradle.parent != null) m.build = identity(p.rootProject)
def sets = p.extensions.findByName('sourceSets')
if (sets != null) {
// Test source sets as IDEA classifies them: `test`, test fixtures, JVM test suites,
// and sets whose folders the `idea` plugin lists as test sources.
def suites = [] as Set
try { p.extensions.findByName('testing')?.suites?.each { suites << it.sources.name } } catch (ignored) {}
def ideaTests = [] as Set
try { p.extensions.findByName('idea')?.module?.testSources?.files?.each { ideaTests << canon(it) } } catch (ignored) {}
m.sourceSets = sets.collect { s ->
def res = s.resources.srcDirs.collect(canon)
def src = s.allSource.srcDirs.collect(canon) - res
def test = s.name == 'test' || s.name == 'testFixtures' || suites.contains(s.name) ||
s.name.endsWith('Test') || s.name.endsWith('Tests') ||
(!src.isEmpty() && src.every { ideaTests.contains(it) })
[name: s.name, test: test, src: src, res: res]
}
}
try {
def java = p.extensions.findByName('java')
def lv = java?.toolchain?.languageVersion?.getOrNull()
m.java = lv != null ? lv.asInt() : (java?.targetCompatibility?.majorVersion as Integer)
} catch (ignored) {}
println '@openidea-model\t' + JsonOutput.toJson(m)
}
gradle.projectsEvaluated { g -> g.rootProject.allprojects { describe(it) } }
+340 -37
View File
@@ -32,7 +32,8 @@ pub struct GradleModel {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GradleProject {
/// Gradle path: `:` for the root project, `:app`, `:libs:core`.
/// Gradle path: `:` for the root project, `:app`, `:libs:core`; for a project of a
/// nested build its identity path (`:lib:core`), which addresses its tasks from the root.
pub path: String,
pub name: String,
/// Project-relative directory.
@@ -40,6 +41,10 @@ pub struct GradleProject {
/// Project-relative build script, if any.
pub build_file: Option<String>,
pub tasks: Vec<GradleTask>,
/// Identity path of the nested build holding the project (`:lib` for `includeBuild`,
/// `:buildSrc`); null for the opened build.
#[serde(default)]
pub build: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -129,35 +134,88 @@ fn include_args(after: &str) -> Vec<String> {
out
}
/// `(root project name, included project paths)` from a settings script.
pub fn parse_settings(text: &str) -> (Option<String>, Vec<String>) {
/// What a settings script declares, read statically.
#[derive(Debug, Default, PartialEq)]
pub struct Settings {
pub name: Option<String>,
/// Included project paths (`:app`), in declaration order.
pub includes: Vec<String>,
/// Project directories that differ from the default `<path>` under the build
/// dir, relative to it: `includeFlat` siblings and literal `projectDir = file(..)`.
pub dirs: BTreeMap<String, String>,
}
/// The included projects, root project name and directory overrides of a settings script.
/// Interpolated (`"$m"`) names and directories cannot be read without Gradle and are skipped.
pub fn parse_settings(text: &str) -> Settings {
let text = strip_comments(text);
let mut name = None;
let mut includes: Vec<String> = Vec::new();
let mut s = Settings::default();
for (i, _) in text.match_indices("rootProject.name") {
let line = text[i + "rootProject.name".len()..].lines().next().unwrap_or_default();
if let Some(n) = literals(line).into_iter().next() {
name = Some(n);
s.name = Some(n);
}
}
let mut from = 0;
while let Some(i) = text[from..].find("include") {
let at = from + i;
from = at + "include".len();
let flat = text[from..].starts_with("Flat");
if flat {
from += "Flat".len();
}
let before_ok = at == 0 || !text[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.');
let after = &text[from..];
let after_ok = after.starts_with(|c: char| c == '(' || c.is_whitespace());
if !before_ok || !after_ok {
continue;
}
for p in include_args(after) {
for p in include_args(after).into_iter().filter(|p| !p.contains('$')) {
let path = if p.starts_with(':') { p } else { format!(":{p}") };
if path.len() > 1 && !includes.contains(&path) {
includes.push(path);
if path.len() <= 1 {
continue;
}
// `includeFlat 'a'` is the sibling directory `../a` of the root project.
if flat {
s.dirs.insert(path.clone(), format!("../{}", path.trim_start_matches(':')));
}
if !s.includes.contains(&path) {
s.includes.push(path);
}
}
}
(name, includes)
// `project(':a').projectDir = file('../a')`; a later assignment wins, as in Gradle.
for line in text.lines() {
let Some((left, right)) = line.split_once(".projectDir") else { continue };
let Some(right) = right.trim_start().strip_prefix('=') else { continue };
let Some(arg) = right.trim_start().strip_prefix("file") else { continue };
let (Some(path), Some(dir)) = (
left.rsplit_once("project").map(|(_, p)| literals(p)).and_then(|l| l.into_iter().next()),
literals(arg).into_iter().next(),
) else {
continue;
};
if !path.contains('$') && !dir.contains('$') {
let path = if path.starts_with(':') { path } else { format!(":{path}") };
s.dirs.insert(path, dir);
}
}
s
}
/// `a/b/../c` → `a/c`, without touching the filesystem.
fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir if out.file_name().is_some() => {
out.pop();
}
c => out.push(c),
}
}
out
}
/// The directory of the Gradle build under `root`: the root itself, or a direct
@@ -235,31 +293,159 @@ pub fn default_tasks(is_root: bool, script: &str) -> Vec<GradleTask> {
}
/// Reads the Gradle build under `root` (no Gradle run), or `None` when there is none.
fn read_first(d: &Path, names: &[&str]) -> Option<(String, String)> {
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
}
/// The project `path` in `pdir`, with the default tasks of its build script.
/// Outside `root` the directory stays absolute (`rel` falls back).
fn project_at(root: &Path, path: String, name: String, pdir: &Path) -> GradleProject {
let script = read_first(pdir, BUILD_FILES);
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
GradleProject {
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
dir: rel(root, pdir),
path,
name,
tasks,
build: None,
}
}
/// A project as Gradle evaluated it (the sync's init script, `gradle-model.gradle`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportedProject {
pub path: String,
/// Absolute, canonical.
pub dir: String,
/// Absolute output folder (`layout.buildDirectory`).
#[serde(default)]
pub build_dir: Option<String>,
#[serde(default)]
pub source_sets: Vec<ReportedSourceSet>,
/// Toolchain language version, else target compatibility.
#[serde(default)]
pub java: Option<u32>,
/// Identity path of the nested build (included build, buildSrc) reporting the project.
#[serde(default)]
pub build: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ReportedSourceSet {
pub name: String,
pub test: bool,
/// Absolute source folders (Java, Kotlin, Groovy, …), which need not exist.
#[serde(default)]
pub src: Vec<String>,
/// Absolute resource folders.
#[serde(default)]
pub res: Vec<String>,
}
const MODEL_LINE: &str = "@openidea-model\t";
/// The projects printed by `gradle-model.gradle`; malformed lines are skipped.
pub fn parse_reported_projects(output: &str) -> Vec<ReportedProject> {
output.lines().filter_map(|l| serde_json::from_str(l.trim_end().strip_prefix(MODEL_LINE)?).ok()).collect()
}
/// Source roots of the reported source sets: `(absolute folder, is test, is resources)`.
pub fn reported_source_roots(reported: &[ReportedProject]) -> Vec<(PathBuf, ReportedRoot)> {
let mut out: Vec<(PathBuf, ReportedRoot)> = Vec::new();
for p in reported {
for set in &p.source_sets {
let (src, res) = if set.test {
(ReportedRoot::Test, ReportedRoot::TestResources)
} else {
(ReportedRoot::Source, ReportedRoot::Resources)
};
for (dir, kind) in set.src.iter().map(|d| (d, src)).chain(set.res.iter().map(|d| (d, res))) {
let dir = PathBuf::from(dir);
// A folder shared by `main` and another set is `main`'s, as in IDEA.
if !out.iter().any(|(d, _)| *d == dir) {
out.push((dir, kind));
}
}
}
// IDEA excludes the build folder of every module.
if let Some(b) = &p.build_dir {
out.push((PathBuf::from(b), ReportedRoot::Excluded));
}
}
out
}
/// Role of a folder a Gradle sync reported.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReportedRoot {
Source,
Test,
Resources,
TestResources,
Excluded,
}
/// Replaces the statically read projects with the ones Gradle reported (root first):
/// settings scripts may compute their projects, which no static reading sees.
pub fn merge_reported_projects(root: &Path, model: &mut GradleModel, reported: &[ReportedProject]) {
if reported.is_empty() {
return;
}
let root_name = model.projects.iter().find(|p| p.path == ":").map(|p| p.name.clone());
model.projects = reported
.iter()
.map(|p| {
let name = match p.path.as_str() {
":" => root_name.clone().unwrap_or_default(),
path => path.rsplit(':').next().unwrap_or_default().to_owned(),
};
let mut project = project_at(root, p.path.clone(), name, Path::new(&p.dir));
// buildSrc is built before the build, never addressable as `:buildSrc:<task>`.
if p.build.as_deref().is_some_and(|b| b.ends_with(":buildSrc")) {
project.tasks.clear();
}
project.build = p.build.clone();
project
})
.collect();
// Each build reports its projects when it is evaluated (nested builds first).
model.projects.sort_by(|a, b| a.path.cmp(&b.path));
// One JDK runs the whole build: the newest any module needs.
if let Some(v) = reported.iter().filter_map(|p| p.java).max() {
model.java_version = Some(v);
}
}
/// Project directories outside `root`, outermost only (`../svc`, not also `../svc/sources/app`):
/// the folders IDEA shows as extra top-level module roots.
pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = dirs.into_iter().map(|d| root.join(d)).filter(|d| !d.starts_with(root)).collect();
dirs.sort();
dirs.dedup();
let mut out: Vec<PathBuf> = Vec::new();
for d in dirs {
if !out.iter().any(|o| d.starts_with(o)) {
out.push(d);
}
}
out
}
pub fn load(root: &Path) -> Option<GradleModel> {
let dir = build_dir(root)?;
let read = |d: &Path, names: &[&str]| {
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
};
let (name, includes) = read(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
let root_name =
name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
let mut projects = Vec::new();
let mut add = |path: String, name: String, pdir: PathBuf| {
let script = read(&pdir, BUILD_FILES);
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
projects.push(GradleProject {
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
dir: rel(root, &pdir),
path,
name,
tasks,
});
};
add(":".into(), root_name, dir.clone());
for path in includes {
let pdir = dir.join(path.trim_start_matches(':').replace(':', "/"));
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
let mut projects = vec![project_at(root, ":".into(), root_name, &dir)];
for path in settings.includes {
let pdir = match settings.dirs.get(&path) {
Some(d) => normalize(&dir.join(d)),
None => dir.join(path.trim_start_matches(':').replace(':', "/")),
};
let name = path.rsplit(':').next().unwrap_or_default().to_owned();
add(path, name, pdir);
projects.push(project_at(root, path, name, &pdir));
}
let java_version = projects
.iter()
@@ -358,9 +544,32 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
out.extend(SETTINGS_FILES.iter().map(|f| nested.join(f)).filter(|f| f.is_file()));
}
// What IDEA's auto-import also watches: properties, the wrapper and version catalogs.
let extra = ["gradle.properties", "gradle/wrapper/gradle-wrapper.properties"].map(|f| dir.join(f));
out.extend(extra.into_iter().filter(|p| p.is_file()));
if let Ok(entries) = std::fs::read_dir(dir.join("gradle")) {
let mut catalogs: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.to_string_lossy().ends_with(".versions.toml") && p.is_file())
.collect();
catalogs.sort();
out.extend(catalogs);
}
out
}
/// A build script without comments and with whitespace runs collapsed, so editing a
/// comment or reformatting does not make a sync stale (IDEA hashes lexer tokens alike).
pub(crate) fn script_fingerprint(text: &str) -> String {
strip_comments(text).split_whitespace().collect::<Vec<_>>().join(" ")
}
/// `(project path, task)` from `gradle tasks --all` console output; tasks listed as
/// `app:run` belong to `:app`, unqualified ones to the root project `:`.
pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
@@ -451,14 +660,60 @@ dependencies {
/* include("block") */
includeBuild("plugins")
"#;
let (name, includes) = parse_settings(kts);
assert_eq!(name.as_deref(), Some("gradle-demo"));
assert_eq!(includes, [":app", ":lib", ":libs:core"]);
let s = parse_settings(kts);
assert_eq!(s.name.as_deref(), Some("gradle-demo"));
assert_eq!(s.includes, [":app", ":lib", ":libs:core"]);
assert!(s.dirs.is_empty());
let groovy = "rootProject.name = 'demo'\ninclude 'a',\n 'b'\ninclude ':c'\nprintln 'x'\n";
let (name, includes) = parse_settings(groovy);
assert_eq!(name.as_deref(), Some("demo"));
assert_eq!(includes, [":a", ":b", ":c"]);
let s = parse_settings(groovy);
assert_eq!(s.name.as_deref(), Some("demo"));
assert_eq!(s.includes, [":a", ":b", ":c"]);
}
#[test]
fn settings_include_flat_and_project_dirs() {
let groovy = r#"
includeFlat 'svc-a'
includeFlat 'svc-b', 'svc-c' // trailing comment
['x', 'y'].each { m -> include ":svc-a:$m" }
include ':wo'
project(':wo').projectDir = file('../svc-a/.worktrees/wo')
project(":dyn").projectDir = file("${rootDir}/dyn")
includeFlated 'not-a-call'
"#;
let s = parse_settings(groovy);
assert_eq!(s.includes, [":svc-a", ":svc-b", ":svc-c", ":wo"]);
assert_eq!(
s.dirs.into_iter().collect::<Vec<_>>(),
[
(":svc-a".into(), "../svc-a".into()),
(":svc-b".into(), "../svc-b".into()),
(":svc-c".into(), "../svc-c".into()),
(":wo".into(), "../svc-a/.worktrees/wo".into()),
]
);
let kts = "includeFlat(\"k\")\nproject(\":k\").projectDir = file(\"../other\")";
let s = parse_settings(kts);
assert_eq!(s.includes, [":k"]);
assert_eq!(s.dirs.get(":k").map(String::as_str), Some("../other"));
}
#[test]
fn loads_include_flat_siblings() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path();
std::fs::create_dir_all(ws.join("root")).unwrap();
std::fs::create_dir_all(ws.join("svc")).unwrap();
std::fs::write(ws.join("root/settings.gradle"), "rootProject.name = 'Root'\nincludeFlat 'svc'").unwrap();
std::fs::write(ws.join("svc/build.gradle"), "plugins { id 'java' }").unwrap();
let root = ws.join("root");
let m = load(&root).unwrap();
let svc = m.projects.iter().find(|p| p.path == ":svc").unwrap();
assert_eq!(Path::new(&svc.dir), ws.join("svc"));
assert_eq!(svc.build_file.as_deref().map(Path::new), Some(ws.join("svc/build.gradle").as_path()));
assert!(root.join(svc.build_file.as_ref().unwrap()).is_file());
}
#[test]
@@ -539,4 +794,52 @@ Pattern: clean<TaskName>: Cleans the output files of a task.
let none = tempfile::tempdir().unwrap();
assert!(load(none.path()).is_none());
}
#[test]
fn merges_included_builds_and_build_src_by_identity_path() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path();
for d in ["shop/app", "shop/buildSrc", "lib/core"] {
std::fs::create_dir_all(ws.join(d)).unwrap();
}
std::fs::write(ws.join("shop/settings.gradle"), "rootProject.name = 'shop'\ninclude 'app'").unwrap();
std::fs::write(ws.join("lib/settings.gradle"), "include 'core'").unwrap();
let root = ws.join("shop");
// In evaluation order, as the init script prints it: nested builds first.
let line = |path: &str, d: &str, build: Option<&str>| {
let project = ReportedProject {
path: path.into(),
dir: ws.join(d).to_string_lossy().into_owned(),
build: build.map(Into::into),
..ReportedProject::default()
};
format!("{MODEL_LINE}{}\n", serde_json::to_string(&project).unwrap())
};
let out = [
line(":lib", "lib", Some(":lib")),
line(":lib:core", "lib/core", Some(":lib")),
line(":buildSrc", "shop/buildSrc", Some(":buildSrc")),
line(":", "shop", None),
line(":app", "shop/app", None),
]
.concat();
let mut model = load(&root).unwrap();
merge_reported_projects(&root, &mut model, &parse_reported_projects(&out));
let projects: Vec<_> =
model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str(), p.build.as_deref())).collect();
assert_eq!(
projects,
[
(":", "shop", None),
(":app", "app", None),
(":buildSrc", "buildSrc", Some(":buildSrc")),
(":lib", "lib", Some(":lib")),
(":lib:core", "core", Some(":lib")),
]
);
let build_src = &model.projects[2];
assert!(build_src.tasks.is_empty(), "buildSrc tasks cannot run from the root build");
assert!(!model.projects[4].tasks.is_empty());
assert!(build_files(&root, &model).contains(&ws.join("lib/settings.gradle")));
}
}
+12
View File
@@ -65,6 +65,18 @@ pub fn load_linked(
BuildModel { maven, gradle }
}
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
/// extra content roots, as IDEA lists module roots beside the project folder.
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
}
/// A library the project depends on, as `groupId:artifactId:version`.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DependencyCoordinate {
+180 -8
View File
@@ -4,14 +4,16 @@
//! per project (`maven.json`, `gradle.json` in the cache directory) and merged
//! into the static model by [`crate::load`]; a changed build file marks them stale.
use crate::gradle::{self, GradleModel, GradleTask};
use crate::gradle::{self, GradleModel, GradleTask, ReportedProject, parse_reported_projects};
use crate::maven::{self, MavenDependency, MavenModel, MavenPlugin};
use crate::process::{self, Output};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use specta::Type;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::SystemTime;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -68,8 +70,14 @@ struct GradleCache {
at_ms: f64,
error: Option<String>,
tasks: Vec<(String, GradleTask)>,
/// The projects as Gradle evaluated them; empty in caches from before they were read.
#[serde(default)]
projects: Vec<ReportedProject>,
}
/// Init script printing every project, its source sets and Java version once configured.
const MODEL_INIT_SCRIPT: &str = include_str!("gradle-model.gradle");
const MAVEN_CACHE: &str = "maven.json";
const MAVEN_CACHE_VERSION: u32 = 1;
@@ -122,6 +130,20 @@ fn write_cache<T: Serialize>(dir: &Path, name: &str, value: &T) -> IdeResult<()>
/// FNV-1a over the paths and contents of `files` (missing files count as empty).
fn hash_files(files: &[PathBuf]) -> String {
hash_contents(files, |f| std::fs::read(f).unwrap_or_default())
}
/// Like IDEA's auto-import CRC: editing a comment or reformatting a build script
/// does not make the last sync stale.
fn hash_gradle_files(files: &[PathBuf]) -> String {
hash_contents(files, |f| {
let text = std::fs::read_to_string(f).unwrap_or_default();
let script = f.extension().is_some_and(|e| e == "gradle" || e == "kts");
if script { gradle::script_fingerprint(&text) } else { text }.into_bytes()
})
}
fn hash_contents(files: &[PathBuf], read: impl Fn(&Path) -> Vec<u8>) -> String {
let mut h: u64 = 0xcbf29ce484222325;
let mut feed = |bytes: &[u8]| {
for b in bytes {
@@ -132,7 +154,7 @@ fn hash_files(files: &[PathBuf]) -> String {
for f in files {
feed(f.to_string_lossy().as_bytes());
feed(&[0]);
feed(&std::fs::read(f).unwrap_or_default());
feed(&read(f));
feed(&[0]);
}
format!("{h:016x}")
@@ -168,9 +190,33 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
}
}
/// The projects of the last successful Gradle sync.
/// Parsed once per change of the cache file (modification time and size): content and source
/// roots are read on hot paths (every references search, Find in Files, repository scans).
pub fn reported_gradle_projects(cache_dir: &Path) -> Arc<Vec<ReportedProject>> {
type Memo = HashMap<PathBuf, (Option<SystemTime>, u64, Arc<Vec<ReportedProject>>)>;
static MEMO: LazyLock<Mutex<Memo>> = LazyLock::new(Default::default);
let path = cache_dir.join(GRADLE_CACHE);
let Ok(meta) = std::fs::metadata(&path) else {
MEMO.lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
return Arc::default();
};
let stamp = (meta.modified().ok(), meta.len());
let mut memo = MEMO.lock().unwrap_or_else(|e| e.into_inner());
if let Some((modified, len, projects)) = memo.get(&path)
&& (*modified, *len) == stamp
{
return projects.clone();
}
let projects = Arc::new(read_cache::<GradleCache>(cache_dir, GRADLE_CACHE).map(|c| c.projects).unwrap_or_default());
memo.insert(path, (stamp.0, stamp.1, projects.clone()));
projects
}
/// Merges the cached task list into `model`.
pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel) {
let Some(cache) = read_cache::<GradleCache>(cache_dir, GRADLE_CACHE) else { return };
gradle::merge_reported_projects(root, model, &cache.projects);
if !cache.tasks.is_empty() {
for p in &mut model.projects {
let tasks: Vec<GradleTask> =
@@ -180,9 +226,11 @@ pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel
}
}
}
// A successful sync from before projects were cached has not seen computed includes.
let without_projects = cache.error.is_none() && cache.projects.is_empty();
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = cache.hash != hash_files(&gradle::build_files(root, model));
model.stale = without_projects || cache.hash != hash_gradle_files(&gradle::build_files(root, model));
}
// ---------------------------------------------------------------- processes
@@ -322,8 +370,18 @@ pub fn sync_gradle(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
let mut args: Vec<String> = vec!["--console=plain".into(), "-q".into(), "tasks".into(), "--all".into()];
let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
std::fs::create_dir_all(cache_dir)?;
let init_script = cache_dir.join("openidea-model.gradle");
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
let mut args: Vec<String> = vec![
"--console=plain".into(),
"-q".into(),
"--init-script".into(),
init_script.to_string_lossy().into_owned(),
"tasks".into(),
"--all".into(),
];
if options.offline {
args.push("--offline".into());
}
@@ -336,11 +394,15 @@ pub fn sync_gradle(
cancel,
|_| {},
)?;
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
// Hash the build files of the reported projects, or the model is stale right after the sync.
gradle::merge_reported_projects(root, &mut model, &projects);
let cache = GradleCache {
hash: hash_files(&gradle::build_files(root, &model)),
hash: hash_gradle_files(&gradle::build_files(root, &model)),
at_ms: now_ms(),
error: (!out.success).then(|| error_summary(&out.text)),
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
projects,
};
write_cache(cache_dir, GRADLE_CACHE, &cache)
}
@@ -456,6 +518,93 @@ mod tests {
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
}
#[test]
fn gradle_reported_projects_replace_static_ones() {
let tmp = tempfile::tempdir().unwrap();
let ws = dunce::canonicalize(tmp.path()).unwrap();
let root = ws.join("app");
let billing = ws.join("billing");
std::fs::create_dir_all(root.join("shared")).unwrap();
std::fs::create_dir_all(billing.join("modules/core")).unwrap();
// Computed includes: nothing a static reading can see.
std::fs::write(root.join("settings.gradle"), "rootProject.name = 'Shop'\n['billing'].each { includeFlat it }")
.unwrap();
std::fs::write(billing.join("modules/core/build.gradle"), "apply plugin: 'java'").unwrap();
// Built with serde: a Windows path holds backslashes, which raw JSON text would leave unescaped.
let line = |p: ReportedProject| format!("@openidea-model\t{}\n", serde_json::to_string(&p).unwrap());
let (r, b) = (root.display().to_string(), billing.display().to_string());
let core_src = |p: &str| format!("{b}/modules/core/src/{p}");
let set = |name: &str, test: bool, src: &[&str], res: &[&str]| gradle::ReportedSourceSet {
name: name.into(),
test,
src: src.iter().map(|p| core_src(p)).collect(),
res: res.iter().map(|p| core_src(p)).collect(),
};
// A settings script's own println comes first; task listing lines follow.
let out = format!(
"configuring billing\n{}{}{}@openidea-model\t{{broken\n\nOther tasks\n-----------\nbilling:core:jar\n",
line(ReportedProject { path: ":".into(), dir: r.clone(), ..ReportedProject::default() }),
line(ReportedProject {
path: ":billing".into(),
dir: b.clone(),
java: Some(17),
..ReportedProject::default()
}),
line(ReportedProject {
path: ":billing:core".into(),
dir: format!("{b}/modules/core"),
java: Some(21),
source_sets: vec![
set("main", false, &["main/java"], &["main/resources"]),
set("test", true, &["main/java", "test/java"], &[]),
],
..ReportedProject::default()
}),
);
let projects = parse_reported_projects(&out);
assert_eq!(projects.len(), 3, "the malformed line is skipped");
let src = |p: &str| PathBuf::from(core_src(p));
assert_eq!(
gradle::reported_source_roots(&projects),
[
(src("main/java"), gradle::ReportedRoot::Source),
(src("main/resources"), gradle::ReportedRoot::Resources),
(src("test/java"), gradle::ReportedRoot::Test),
]
);
let mut model = gradle::load(&root).unwrap();
assert_eq!(model.projects.len(), 1);
gradle::merge_reported_projects(&root, &mut model, &projects);
let paths: Vec<_> = model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str())).collect();
assert_eq!(paths, [(":", "Shop"), (":billing", "billing"), (":billing:core", "core")]);
assert_eq!(model.projects[0].dir, "");
assert!(root.join(model.projects[2].build_file.as_ref().unwrap()).is_file());
assert_eq!(
gradle::external_roots(&root, model.projects.iter().map(|p| p.dir.as_str())),
std::slice::from_ref(&billing)
);
assert_eq!(model.java_version, Some(21), "the newest version any module needs");
assert_eq!(
gradle::parse_tasks(&out),
[(":billing:core".into(), GradleTask { name: "jar".into(), group: "other".into(), description: None })]
);
// The cached projects are parsed once per change of the cache file.
let cache_dir = ws.join("cache");
let write = |projects: &[ReportedProject]| {
let cache = GradleCache { projects: projects.to_vec(), ..GradleCache::default() };
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
};
write(&projects);
let first = reported_gradle_projects(&cache_dir);
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
write(&projects[..1]);
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
assert!(reported_gradle_projects(&cache_dir).is_empty());
}
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();
@@ -466,16 +615,39 @@ mod tests {
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
let mut model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_files(&gradle::build_files(&root, &model)),
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
at_ms: 1.0,
error: None,
tasks: vec![(":app".into(), GradleTask { name: "hello".into(), group: "demo".into(), description: None })],
projects: [":", ":app"]
.iter()
.zip([root.clone(), root.join("app")])
.map(|(path, dir)| ReportedProject {
path: (*path).into(),
dir: dir.to_string_lossy().into_owned(),
..Default::default()
})
.collect(),
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
assert_eq!(model.projects[1].tasks.len(), 1);
assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults");
assert!(!model.stale);
let stale_after = |script: &str| {
std::fs::write(root.join("app/build.gradle"), script).unwrap();
let mut m = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut m);
m.stale
};
assert!(!stale_after("// a note\napply plugin: 'java'\n"), "comments and spacing don't count");
assert!(stale_after("apply plugin: 'groovy'"));
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
std::fs::write(root.join("gradle.properties"), "org.gradle.jvmargs=-Xmx2g").unwrap();
let mut m = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut m);
assert!(m.stale, "gradle.properties is watched");
}
#[test]
+14
View File
@@ -786,6 +786,19 @@ pub struct GradleSettings {
pub home: String,
/// Empty = Gradle's default user home.
pub user_home: String,
/// IDEA's "Build and run using": who builds and launches an Application main.
pub run_using: GradleRunUsing,
}
/// IDEA's "Build and run using" for Gradle builds; IDEA defaults to Gradle.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum GradleRunUsing {
/// A `JavaExec` task on the source set's runtime classpath (`gradleRun.gradle`).
#[default]
Gradle,
/// The Java language server's classpath, launched by OpenIDEA.
Ide,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -1026,6 +1039,7 @@ mod gradle_settings_tests {
distribution,
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
run_using: GradleRunUsing::Ide,
};
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
+7
View File
@@ -150,6 +150,13 @@ pub async fn build_sync(
.map_err(join_err);
host.end_request(request);
let model = result??;
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
+92 -14
View File
@@ -91,7 +91,8 @@ impl Host {
let settings = SettingsStore::load(&self.config_dir, Some(&root));
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read());
let extra_roots = content_roots(&settings.effective(), &root);
let build_cache = self.local_data_dir.join("cache").join(project_key(&root)).join("build");
let extra_roots = content_roots(&settings.effective(), &root, &build_cache);
mark("roots");
// The project's own registry: File Types associations may be project-scoped.
@@ -265,7 +266,7 @@ impl Host {
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
root.clone(),
self.local_data_dir.join("cache").join(&project_key).join("build"),
build_cache.clone(),
&self.local_data_dir,
settings.clone(),
lsp.clone(),
@@ -292,6 +293,7 @@ impl Host {
let runtime = self.runtime.clone();
let lsp = lsp.clone();
let trusted = trusted.clone();
let build_cache = build_cache.clone();
Watcher::start(
&root.clone(),
excludes.clone(),
@@ -328,7 +330,7 @@ impl Host {
// Detected excluded folders (`target`, `build`) leave the index too.
let effective = settings.effective();
index.set_scope(
index_roots(&root, content_roots(&effective, &root)),
index_roots(&root, content_roots(&effective, &root, &build_cache)),
exclude_globs(&root, &effective, &fresh),
);
*detected_roots.write() = fresh;
@@ -366,7 +368,7 @@ impl Host {
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink);
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
}
let vcs_moved = batch.vcs.iter().any(|p| vcs_path_changed(p));
if git_dirs || vcs_moved {
@@ -413,9 +415,10 @@ impl Host {
mark("watcher");
{
let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone());
let build_cache = build_cache.clone();
std::thread::Builder::new()
.name("openidea-vcs-roots".into())
.spawn(move || scan_repositories(&root, &repos, &settings, &sink))?;
.spawn(move || scan_repositories(&root, &repos, &settings, &sink, &build_cache))?;
}
mark("git");
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
@@ -650,11 +653,17 @@ fn light_watcher(
/// Looks for repositories nested below the project (and the one containing it);
/// emits `VcsChanged` when the set of roots changed.
fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStore, sink: &SharedSink) {
fn scan_repositories(
root: &Path,
repos: &RwLock<Repos>,
settings: &SettingsStore,
sink: &SharedSink,
build_cache: &Path,
) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root));
roots.extend(content_roots(&settings.effective(), root, build_cache));
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
@@ -668,7 +677,7 @@ fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStor
impl Project {
pub fn refresh_repositories(&self) {
scan_repositories(&self.root, &self.repos, &self.settings, &self.sink);
scan_repositories(&self.root, &self.repos, &self.settings, &self.sink, &self.build_cache_dir());
}
/// The project's git repositories.
@@ -774,7 +783,8 @@ impl Project {
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
pub fn roots(&self) -> ProjectRoots {
linked_roots(&self.root, &self.settings.effective(), &self.detected_roots.read())
let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
linked_roots(&self.root, &self.settings.effective(), &detected)
}
/// Where the Maven / Gradle tool windows cache what the build tools reported.
@@ -841,7 +851,7 @@ impl Project {
/// Project Structure content roots besides the project folder (existing directories).
pub fn content_roots(&self) -> Vec<PathBuf> {
content_roots(&self.settings.effective(), &self.root)
content_roots(&self.settings.effective(), &self.root, &self.build_cache_dir())
}
/// Applies Settings › File Types; open documents whose language changed are
@@ -960,14 +970,15 @@ fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
}
/// Workspace members in manifest order, or extra Project Structure directories.
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
/// Workspace members in manifest order, or extra Project Structure directories and the
/// Gradle project folders outside the project as of the last sync (`build_cache`).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
return workspace.projects;
}
let mut out: Vec<PathBuf> = Vec::new();
for r in &settings.project_structure.content_roots {
let p = PathBuf::from(r.trim());
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) {
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
@@ -1024,6 +1035,33 @@ pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Se
ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven).poms().map(PathBuf::from).collect()
}
/// The detected roots with the last Gradle sync's source sets first: Gradle evaluated what
/// static detection guesses. Folders outside the project keep their absolute path, which
/// `normalize` turns into the same form the UI compares (`Users/me/svc/src/main/java`).
fn with_gradle_roots(root: &Path, build_cache: &Path, detected: &[SourceFolder]) -> Vec<SourceFolder> {
use ide_build::gradle::ReportedRoot;
use ide_workspace::roots::RootKind;
let mut out: Vec<SourceFolder> = Vec::new();
for (dir, kind) in ide_build::gradle_source_roots(build_cache) {
if !dir.is_dir() {
continue;
}
let path = ide_workspace::roots::normalize(&dir.strip_prefix(root).unwrap_or(&dir).to_string_lossy());
let kind = match kind {
ReportedRoot::Source => RootKind::Source,
ReportedRoot::Test => RootKind::Test,
ReportedRoot::Resources => RootKind::Resources,
ReportedRoot::TestResources => RootKind::TestResources,
ReportedRoot::Excluded => RootKind::Excluded,
};
if !out.iter().any(|f| f.path == path) {
out.push(SourceFolder { path, kind, detected_from: Some("Gradle".into()) });
}
}
out.extend(detected.iter().filter(|d| !out.iter().any(|o| o.path == d.path)).cloned().collect::<Vec<_>>());
out
}
fn linked_roots(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> ProjectRoots {
let unlinked = ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven);
let detected = detected
@@ -1225,6 +1263,46 @@ fn vcs_path_changed(path: &Path) -> bool {
!path.ends_with(".git/index") || !ide_vcs::is_own_index_write(path)
}
#[cfg(test)]
mod gradle_roots_tests {
use super::*;
use ide_workspace::RootKind;
#[test]
fn gradle_sync_roots_win_and_reach_outside_the_project() {
let tmp = tempfile::tempdir().unwrap();
let ws = dunce::canonicalize(tmp.path()).unwrap();
let (root, svc, cache) = (ws.join("p"), ws.join("svc"), ws.join("cache"));
for d in [root.join("src/it/java"), svc.join("src/main/java"), svc.join("build")] {
std::fs::create_dir_all(d).unwrap();
}
let s = |p: PathBuf| p.to_string_lossy().into_owned();
let json = serde_json::json!({ "hash": "", "at_ms": 1.0, "error": null, "tasks": [], "projects": [
{ "path": ":", "dir": s(root.clone()), "sourceSets": [
{ "name": "integrationTest", "test": true, "src": [s(root.join("src/it/java"))], "res": [] } ] },
{ "path": ":svc", "dir": s(svc.clone()), "buildDir": s(svc.join("build")), "sourceSets": [
{ "name": "main", "test": false,
"src": [s(svc.join("src/main/java")), s(svc.join("src/main/kotlin"))], "res": [] } ] },
]});
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap();
let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None };
let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None };
let out = with_gradle_roots(&root, &cache, &[guessed, other.clone()]);
let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p));
let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect();
assert_eq!(
got,
[
("src/it/java".into(), RootKind::Test),
(abs(svc.join("src/main/java")), RootKind::Source),
(abs(svc.join("build")), RootKind::Excluded),
("web".into(), RootKind::Source),
]
);
}
}
#[cfg(test)]
mod index_event_tests {
use super::vcs_path_changed;
+18 -1
View File
@@ -3489,7 +3489,10 @@ export type GradleModel = {
};
export type GradleProject = {
/** Gradle path: `:` for the root project, `:app`, `:libs:core`. */
/**
* Gradle path: `:` for the root project, `:app`, `:libs:core`; for a project of a
* nested build its identity path (`:lib:core`), which addresses its tasks from the root.
*/
path: string,
name: string,
/** Project-relative directory. */
@@ -3497,8 +3500,20 @@ export type GradleProject = {
/** Project-relative build script, if any. */
buildFile: string | null,
tasks: GradleTask[],
/**
* Identity path of the nested build holding the project (`:lib` for `includeBuild`,
* `:buildSrc`); null for the opened build.
*/
build?: string | null,
};
/** IDEA's "Build and run using" for Gradle builds; IDEA defaults to Gradle. */
export type GradleRunUsing =
/** A `JavaExec` task on the source set's runtime classpath (`gradleRun.gradle`). */
"gradle" |
/** The Java language server's classpath, launched by OpenIDEA. */
"ide";
/** Build Tools › Gradle. */
export type GradleSettings = {
/** Empty = Project JDK; otherwise a JDK home. */
@@ -3508,6 +3523,8 @@ export type GradleSettings = {
home?: string,
/** Empty = Gradle's default user home. */
userHome?: string,
/** IDEA's "Build and run using": who builds and launches an Application main. */
runUsing?: GradleRunUsing,
};
export type GradleTask = {
+2 -1
View File
@@ -43,7 +43,8 @@ export const MAVEN_WHEN = "projectOpen && build.maven";
export const GRADLE_WHEN = "projectOpen && build.gradle";
/** Build files whose creation / change / removal re-reads the model. */
const BUILD_FILE = /(?:^|\/)(?:pom\.xml|(?:build|settings)\.gradle(?:\.kts)?|mvnw(?:\.cmd)?|gradlew(?:\.bat)?)$/;
const BUILD_FILE =
/(?:^|\/)(?:pom\.xml|(?:build|settings)\.gradle(?:\.kts)?|mvnw(?:\.cmd)?|gradlew(?:\.bat)?|gradle\.properties|gradle-wrapper\.properties|[^/]+\.versions\.toml)$/;
function expandAll(tool: BuildTool) {
const { model, configs } = useBuild.getState();
+17
View File
@@ -23,6 +23,7 @@ import {
type SyncProgress,
} from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
import { editorService, notifications } from "@/workbench/contributions";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
@@ -74,6 +75,14 @@ export const useBuild = create<BuildState>(() => ({
current: {},
}));
// Shared with the features that follow the build (run, Project view).
useBuild.subscribe((s) => {
const shared = useBuildModel.getState();
const gradleOffline = s.settings.gradle.offline;
if (shared.root !== s.root || shared.model !== s.model || shared.gradleOffline !== gradleOffline)
useBuildModel.setState({ root: s.root, model: s.model, gradleOffline });
});
// ---------------------------------------------------------------------------
// Settings (per project)
@@ -189,6 +198,9 @@ export function changedOutsideIde(paths: readonly string[], now = Date.now()): b
return external;
}
/** Root whose never-synced Gradle build was imported on open (once per open). */
let autoImported: string | null = null;
/** Re-reads the build model (and the run configurations) of the open project. */
export async function loadModel(): Promise<void> {
const root = useProject.getState().project?.root ?? null;
@@ -218,6 +230,11 @@ export async function loadModel(): Promise<void> {
const maven = model.maven;
if (maven?.stale && maven.syncedAt !== null && !useBuild.getState().syncing.maven) void syncTool("maven");
}
// Like IDEA, a Gradle build is imported when first opened; later changes wait for Reload.
if (model.gradle && model.gradle.syncedAt === null && autoImported !== root) {
autoImported = root;
void syncTool("gradle");
}
} catch (e) {
if (gen === generation) useBuild.setState({ loading: false, error: e instanceof Error ? e.message : String(e) });
}
+3 -1
View File
@@ -132,7 +132,9 @@ function iconFor(
const kind = folderKind(node, roots, root, build, detected);
if (kind !== "plain") return folderIconName(kind);
const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null;
const module = rel === null ? null : moduleFolderIcon(rel, node.root, build.modules);
// Modules outside the project are keyed by their absolute folder.
const key = rel !== null && build.modules.has(rel) ? rel : node.path;
const module = root ? moduleFolderIcon(key, node.root, build.modules) : null;
if (module) return module;
return node.root ? "module" : "folder";
}
+19 -4
View File
@@ -1,6 +1,7 @@
/** Project view: the "project" tool window, its file operations and actions. */
import { onEditorEvent, openEditorPaths } from "@/editor/api";
import { onIdeEvent } from "@/platform/ipc";
import { useBuildModel } from "@/platform/buildModel";
import { validateProjectStructure, type RootFolder } from "@/platform/languageSettings";
import { useProject } from "@/platform/project";
import { initProjectRoots, useProjectRoots } from "@/platform/projectRoots";
@@ -15,6 +16,7 @@ import {
useProjectViewRoots,
} from "@/workbench/contributions";
import { registerProjectActions } from "./actions";
import { gradleModuleGroups } from "./moduleGroups";
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
import { NewPopupHost } from "./NewPopup";
import { RefactoringPreviewHost } from "./RefactoringPreview";
@@ -65,14 +67,20 @@ export function activate(): () => void {
if (s.root && s.root === prev.root && detectedExcluded(s) !== detectedExcluded(prev)) void reloadAll();
});
// Project Structure content roots besides the project folder, listed after it.
// Project Structure content roots after the project folder, and IDEA's module groups of
// a Gradle build with modules outside it (as of the last sync) before it.
let contentRoots: Array<() => void> = [];
let shownRoots = "";
const syncContentRoots = () => {
const { project, settings } = useProject.getState();
const paths =
project && !project.workspace ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
const key = JSON.stringify(paths);
const own = project && !project.workspace;
const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
// The Build tool window's model (one load per build change), not a load of our own.
const build = useBuildModel.getState();
const gradleProjects = build.root === project?.root ? (build.model?.gradle?.projects ?? []) : [];
const rootName = gradleProjects.find((p) => p.path === ":")?.name ?? "";
const groups = own && project ? gradleModuleGroups(gradleProjects, rootName, project.root) : [];
const key = JSON.stringify([paths, groups]);
if (key === shownRoots) return;
shownRoots = key;
contentRoots.forEach((d) => d());
@@ -86,8 +94,14 @@ export function activate(): () => void {
order: -100 + i,
}),
);
groups.forEach((g, i) =>
contentRoots.push(registerProjectViewRoot({ ...g, id: g.id!, order: -200 + i, beforeProject: true })),
);
};
syncContentRoots();
const unsubGradleRoots = useBuildModel.subscribe((s, prev) => {
if (s.model !== prev.model || s.root !== prev.root) syncContentRoots();
});
const unsubContentRoots = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings || s.project !== prev.project) syncContentRoots();
});
@@ -124,6 +138,7 @@ export function activate(): () => void {
unsubExtraRoots,
unsubExcludes,
unsubContentRoots,
unsubGradleRoots,
() => contentRoots.forEach((d) => d()),
disposeRoots,
unsubRoots,
+16 -3
View File
@@ -249,6 +249,8 @@ export interface ExtraRoot {
children?: readonly ExtraRoot[];
detail?: string;
readOnly?: boolean;
/** Listed above the project folder (IDEA's module group of the build). */
beforeProject?: boolean;
}
/** Key prefix of group nodes that show no folder ("External Libraries", an SDK). */
@@ -387,13 +389,24 @@ export function withExtraRoots(
readOnly: true,
};
out.byKey.set(key, node);
const kids = (extra.children ?? []).map((c) => add(c, key, ro)).filter((n): n is ProjectNode => !!n);
out.children.set(key, kids);
// Deepest folders first: a module folder nested in a sibling's (`billing/modules/core`
// in `billing`) would otherwise already be keyed by the sibling's listing and dropped.
const children = extra.children ?? [];
const depth = (c: ExtraRoot) => c.path?.split("/").length ?? 0;
const built: (ProjectNode | null)[] = [];
for (const i of children.map((_, i) => i).sort((a, b) => depth(children[b]!) - depth(children[a]!)))
built[i] = add(children[i]!, key, ro);
out.children.set(
key,
built.filter((n): n is ProjectNode => !!n),
);
return node;
};
let before = 0;
for (const extra of extras) {
const node = add(extra, null, false);
if (node) out.roots.push(node);
if (node && extra.beforeProject) out.roots.splice(before++, 0, node);
else if (node) out.roots.push(node);
}
return out;
}
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import type { FileEntry } from "@/bindings";
import { buildModel, DEFAULT_TREE_OPTIONS, type ExtraRoot, withExtraRoots } from "./model";
import { gradleModuleGroups } from "./moduleGroups";
type Shown = { label: string; detail?: string; children?: Shown[] };
const shape = (e: ExtraRoot): Shown => ({
label: e.label,
...(e.detail ? { detail: e.detail } : {}),
...(e.children ? { children: e.children.map(shape) } : {}),
});
describe("gradleModuleGroups (IDEA's module groups)", () => {
// `includeFlat` sibling modules, one with layer modules nested in its folder.
const flat = [
{ path: ":", dir: "" },
{ path: ":common", dir: "/w/common" },
{ path: ":billing", dir: "/w/billing" },
...["core", "api"].map((l) => ({ path: `:billing:${l}`, dir: `/w/billing/modules/${l}` })),
];
it("lists every module of the group, nested ones flattened, as IntelliJ shows it", () => {
expect(gradleModuleGroups(flat, "Shop", "/w/shop").map(shape)).toEqual([
{
label: "Shop",
children: [
{ label: "api", detail: "Shop.billing.api" },
{ label: "billing", detail: "Shop.billing" },
{ label: "common", detail: "Shop.common" },
{ label: "core", detail: "Shop.billing.core" },
],
},
]);
});
it("shows no group while every module lives in the project folder", () => {
const inside = [
{ path: ":", dir: "" },
{ path: ":app", dir: "app" },
{ path: ":libs:core", dir: "libs/core" },
];
expect(gradleModuleGroups(inside, "demo", "/w/demo")).toEqual([]);
});
it("keeps a subgroup that no module is named like", () => {
const projects = [
{ path: ":", dir: "" },
{ path: ":svc", dir: "/w/svc" },
{ path: ":libs:a", dir: "/w/libs/a" },
{ path: ":libs:b", dir: "/w/libs/b" },
];
expect(gradleModuleGroups(projects, "R", "/w/r").map(shape)).toEqual([
{
label: "R",
children: [
{
label: "libs",
children: [
{ label: "a", detail: "R.libs.a" },
{ label: "b", detail: "R.libs.b" },
],
},
{ label: "svc", detail: "R.svc" },
],
},
]);
});
it("names an included build's modules after it and lists it as an ungrouped module", () => {
const projects = [
{ path: ":", dir: "" },
{ path: ":app", dir: "app" },
{ path: ":buildSrc", dir: "buildSrc", build: ":buildSrc" },
{ path: ":lib", dir: "/w/lib", build: ":lib" },
{ path: ":lib:core", dir: "/w/lib/core", build: ":lib" },
];
expect(gradleModuleGroups(projects, "Shop", "/w/shop").map(shape)).toEqual([{ label: "lib" }]);
// Beside includeFlat modules: the group (every `Shop.*` module), then the included build.
const both = [...projects, { path: ":svc", dir: "/w/svc" }];
expect(gradleModuleGroups(both, "Shop", "/w/shop").map(shape)).toEqual([
{
label: "Shop",
children: [
{ label: "app", detail: "Shop.app" },
{ label: "buildSrc", detail: "Shop.buildSrc" },
{ label: "svc", detail: "Shop.svc" },
],
},
{ label: "lib" },
]);
});
});
const dir = (path: string): FileEntry => ({
name: path.slice(path.lastIndexOf("/") + 1),
path,
kind: "dir",
size: 0,
mtimeMs: 1,
ignored: false,
excluded: false,
hidden: false,
});
describe("module group nodes in the tree", () => {
it("lists the group above the project and keeps a nested module whose parent is expanded", () => {
const listings = new Map([
["/w/billing", [dir("/w/billing/modules")]],
["/w/billing/modules", [dir("/w/billing/modules/core")]],
]);
const base = buildModel({ path: "/p", name: "p" }, new Map(), DEFAULT_TREE_OPTIONS);
const group = {
label: "Shop",
icon: "gradle-project",
beforeProject: true,
children: [
{ label: "billing", icon: "gradle-module", path: "/w/billing" },
{ label: "core", icon: "gradle-module", path: "/w/billing/modules/core" },
],
};
const m = withExtraRoots(base, [group], listings, DEFAULT_TREE_OPTIONS);
expect(m.roots.map((r) => r.label)).toEqual(["Shop", "p"]);
expect(m.children.get(m.roots[0]!.key)!.map((n) => n.label)).toEqual(["billing", "core"]);
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* IDEA's module groups in the Project view, for Gradle builds whose modules live outside
* the project folder (`includeFlat`, computed `projectDir`s, `includeBuild`). Mirrors IntelliJ's
* `AbstractProjectNode.modulesAndGroups`, `ModuleGroup.modulesInGroup` / `childGroups`
* and the `QualifiedNameGrouper` of qualified module names (`<root project>.billing.core`).
*/
import type { ExtraRoot } from "./model";
import { isSameOrInside, nameOf } from "./paths";
interface Module {
/** Qualified name: `<root project>.<path segments>`. */
name: string;
dir: string;
}
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
const segments = (name: string) => name.split(".");
const groupOf = (m: Module) => segments(m.name).slice(0, -1);
const same = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((x, i) => x === b[i]);
const isChild = (parent: readonly string[], d: readonly string[]) =>
d.length > parent.length && same(d.slice(0, parent.length), parent);
const byLabel = (a: ExtraRoot, b: ExtraRoot) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" });
/**
* Group nodes to list beside the project folder: none while the project folder is the only
* top-level module root (every module inside it), as in IDEA.
*/
export function gradleModuleGroups(
projects: readonly { path: string; dir: string; build?: string | null }[],
rootName: string,
projectRoot: string,
): ExtraRoot[] {
const modules: Module[] = projects.map((p) => {
const path = p.path.split(":").filter(Boolean);
// An included build's modules are named after that build (`lib.core`); buildSrc and
// the opened build's after the root project (`shop.buildSrc`), as IDEA names them.
const own = !p.build || p.build === ":buildSrc";
return {
name: (own ? [rootName, ...path] : path).join("."),
dir: isAbsolute(p.dir) ? p.dir : p.dir ? `${projectRoot}/${p.dir}` : projectRoot,
};
});
// A content root is top level when no other content root contains it (`isTopLevelRoot`).
const roots = [projectRoot, ...modules.map((m) => m.dir)];
const topLevel = modules.filter(
(m) => m.dir === projectRoot || !roots.some((r) => r !== m.dir && isSameOrInside(m.dir, r)),
);
if (topLevel.length <= 1) return [];
const asGroups = modules.map((m) => segments(m.name));
const isModulePath = (path: readonly string[]) => asGroups.some((g) => same(g, path));
const moduleNode = (m: Module): ExtraRoot => {
const label = nameOf(m.dir) || m.dir;
return { id: m.name, label, detail: m.name === label ? undefined : m.name, icon: "gradle-module", path: m.dir };
};
// `modulesAndGroups`: ungrouped top-level modules as they are (the project folder is
// shown anyway), one node for the common group path of the grouped ones.
const loose = topLevel.filter((m) => !groupOf(m).length && m.dir !== projectRoot).map(moduleNode);
const grouped = topLevel.map(groupOf).filter((g) => g.length);
if (!grouped.length) return loose.sort(byLabel);
let common = grouped[0]!;
for (const g of grouped.slice(1)) {
let n = 0;
while (n < Math.min(common.length, g.length) && common[n] === g[n]) n++;
common = common.slice(0, n);
}
const tops = common.length ? [common] : [...new Map(grouped.map((g) => [g[0]!, [g[0]!]])).values()];
const node = (group: readonly string[]): ExtraRoot => {
// `childGroups`: direct subgroups not named like a module (those fold into this group).
const childGroups = new Map<string, string[]>();
for (const m of modules) {
const g = groupOf(m);
const direct = [...group, g[group.length]!];
if (isChild(group, g) && !isModulePath(direct)) childGroups.set(direct.join("."), direct);
}
// `modulesInGroup`: this group's modules, and those under a subgroup named like a module.
const members = modules.filter((m) => {
const g = groupOf(m);
return same(g, group) || (isChild(group, g) && isModulePath(g.slice(0, group.length + 1)));
});
const seen = new Set<string>();
const moduleNodes = members.filter((m) => !seen.has(m.dir) && !!seen.add(m.dir)).map(moduleNode);
return {
id: `gradleGroup:${group.join(".")}`,
label: group[group.length - 1]!,
icon: "gradle-project",
children: [...[...childGroups.values()].map(node).sort(byLabel), ...moduleNodes.sort(byLabel)],
};
};
return [...tops.map(node), ...loose.sort(byLabel)];
}
+10 -3
View File
@@ -5,14 +5,21 @@
*/
import type { RunConfig } from "@/platform/ipc";
import { CLASSPATH, TEST_CLASSPATH, jvmModuleDir } from "@/platform/javaLaunch";
import { isTestPath } from "@/platform/languageSettings";
import { isTestPath, rootFor } from "@/platform/languageSettings";
import { currentRoots } from "@/platform/projectRoots";
import { simpleClassName } from "./jvmTargets";
export { jvmModuleDir } from "@/platform/javaLaunch";
/** In a test source root (detected or marked in Project Structure), or Maven's `src/test`. */
const isTestSource = (relPath: string) => /(^|\/)src\/test\//.test(relPath) || isTestPath(currentRoots(), relPath);
/**
* In a test source root, as IDEA's `getClasspathType` decides: the root holding the file
* (synced from Gradle, detected or marked in Project Structure) wins; `src/test` only
* while no root is known for the file.
*/
const isTestSource = (relPath: string) => {
const roots = currentRoots();
return rootFor(roots, relPath) ? isTestPath(roots, relPath) : /(^|\/)src\/test\//.test(relPath);
};
/** Temporary "Application" configuration that runs `className` (a binary name). */
export function applicationConfig(
+32 -10
View File
@@ -8,6 +8,8 @@ import { activeEditor } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { validateProjectStructure } from "@/platform/languageSettings";
import { useBuildModel } from "@/platform/buildModel";
import { isVirtualUri } from "@/platform/virtualDocs";
import { showContextMenu, type MenuItem } from "@/ui";
import {
@@ -42,6 +44,31 @@ async function ensureConfigs() {
}
/** The focused editor's file, text and caret line. */
/** Project folders outside its root: Project Structure content roots and Gradle modules (as of the last sync). */
export function projectContentRoots(): string[] {
const own = validateProjectStructure(useProject.getState().settings?.projectStructure).contentRoots;
// The build model keeps the folders of modules outside the project absolute.
const gradle = (useBuildModel.getState().model?.gradle?.projects ?? [])
.map((p) => p.dir)
.filter((d) => isAbsolute(d));
return [...new Set([...own, ...gradle])];
}
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
/**
* The project side of every run input (editor, gutter, menus, actions): built in one place
* so no entry point runs a project file as a stray one.
*/
export function projectContext(): Pick<ContextInput, "root" | "configs" | "goModule" | "contentRoots"> {
return {
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
goModule: goModulePath,
contentRoots: projectContentRoots(),
};
}
export function editorInput(): ContextInput | null {
const path = maybeEditorService()?.activePath() ?? activeEditor()?.ctx.path ?? null;
if (!path || isVirtualUri(path)) return null;
@@ -49,12 +76,10 @@ export function editorInput(): ContextInput | null {
const same = editor && editor.ctx.path === path;
const state = same ? editor.view.state : null;
return {
...projectContext(),
path,
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
content: state ? state.doc.sliceString(0, CONTENT_LIMIT) : undefined,
line: state ? state.doc.lineAt(state.selection.main.head).number - 1 : undefined,
goModule: goModulePath,
};
}
@@ -148,7 +173,7 @@ export async function compiledDebugPlan(config: RunConfig): Promise<Produced | n
} catch {
return null;
}
const input: ContextInput = { path, root, configs: useRun.getState().configs, content };
const input: ContextInput = { ...projectContext(), root, path, content };
const env = await toolEnv(neededTools(input), path, root, false);
if (m[2]) {
// A test: find the target by name again.
@@ -262,13 +287,11 @@ function contextArg(arg: unknown): (ContextInput & { dir?: boolean }) | null {
const editor = activeEditor();
const state = editor && editor.ctx.path === a.path ? editor.view.state : null;
return {
...projectContext(),
path: a.path,
dir: a.dir,
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
content: a.content ?? state?.doc.sliceString(0, CONTENT_LIMIT) ?? peekContent(a.path),
line: a.line,
goModule: goModulePath,
};
}
@@ -327,12 +350,11 @@ export function contextMenuItems(menu: ContextMenuId, ctx: MenuGroupContext): Me
const content = ctx.text ?? peekContent(path);
const line = menu === "editor" ? ctx.line : undefined;
const input: ContextInput = {
path,
...projectContext(),
root,
configs: useRun.getState().configs,
path,
content: content?.slice(0, CONTENT_LIMIT),
line,
goModule: goModulePath,
};
const p = dir ? produceDirectory(input) : produceContext(input, OPTIMISTIC);
if (!p) return [];
+31
View File
@@ -0,0 +1,31 @@
// OpenIDEA: runs a main class on its Gradle source set's runtime classpath, like IntelliJ
// IDEA's "Build and run using: Gradle" (ApplicationTaskInitScript.gradle). Parameters come
// as system properties so this file never changes: see `gradleRun.ts`.
import groovy.json.JsonSlurper
def target = System.getProperty('openidea.run.project')
if (target != null) {
allprojects { p ->
// `target` is an identity path (`:lib:core` for a project of an included build).
def id = { try { p.identityPath.toString() } catch (ignored) { p.path } }
if (id() != target) return
p.plugins.withId('java') {
def json = new JsonSlurper()
def list = { String key -> (List) json.parseText(System.getProperty(key, '[]')) }
p.tasks.register('openideaRun', JavaExec) { t ->
t.description = 'Runs a main class for OpenIDEA.'
t.classpath = p.sourceSets.getByName(System.getProperty('openidea.run.sourceSet', 'main')).runtimeClasspath
t.mainClass.set(System.getProperty('openidea.run.main'))
t.args(list('openidea.run.args'))
t.jvmArgs(list('openidea.run.jvmArgs'))
// The IDE attaches to this JVM, not to the Gradle daemon; port 0 prints the one picked.
if (Boolean.getBoolean('openidea.run.debug')) {
t.jvmArgs('-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0')
}
def dir = System.getProperty('openidea.run.workingDir')
if (dir) t.workingDir = dir
t.standardInput = System.in
}
}
}
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { GradleModel, RunConfig } from "@/platform/ipc";
import { applicationConfig } from "./application";
import { DELEGATED_DEBUG, delegatedGradleConfig, wrapperGradleVersion } from "./gradleRun";
import { jdwpLaunch } from "./javaDebug";
import { isJvmApplication } from "@/platform/javaLaunch";
const ROOT = "/w/shop";
const CORE = "/w/billing/modules/core";
const gradle = {
dir: "",
command: "/w/shop/gradlew",
env: { JAVA_HOME: "/jdk21" },
projects: [
{ path: ":", name: "Shop", dir: "", buildFile: null, tasks: [] },
{ path: ":billing:core", name: "core", dir: CORE, buildFile: null, tasks: [] },
{ path: ":app", name: "app", dir: "app", buildFile: null, tasks: [] },
],
} as unknown as GradleModel;
const ctx = {
root: ROOT,
gradle,
script: "/tmp/openidea-gradle-run.gradle",
offline: false,
configurationCache: false,
};
describe("Build and run using: Gradle", () => {
it("runs an Application of an includeFlat module as its JavaExec task", () => {
const app: RunConfig = {
...applicationConfig(
{ className: "com.example.ShopApplication", spring: true },
`${CORE}/src/main/java/com/example/ShopApplication.java`,
[],
),
args: ["-Xmx1g", "-cp", "${classpath}", "com.example.ShopApplication", "--port", "8081 x"],
env: { SPRING_PROFILES_ACTIVE: "dev" },
};
const run = delegatedGradleConfig(app, CORE, ctx)!;
expect(run).toMatchObject({
kind: "gradle",
name: "ShopApplication",
command: "/w/shop/gradlew",
cwd: null,
module: CORE,
env: { JAVA_HOME: "/jdk21", SPRING_PROFILES_ACTIVE: "dev" },
});
expect(run.args).toEqual([
"--console=plain",
"--init-script",
"/tmp/openidea-gradle-run.gradle",
"-Dopenidea.run.project=:billing:core",
"-Dopenidea.run.main=com.example.ShopApplication",
"-Dopenidea.run.sourceSet=main",
'-Dopenidea.run.args=["--port","8081 x"]',
'-Dopenidea.run.jvmArgs=["-Xmx1g"]',
`-Dopenidea.run.workingDir=${CORE}`,
":billing:core:openideaRun",
]);
});
it("uses the test classpath, a project-relative module and offline mode", () => {
const test = applicationConfig({ className: "a.Tool" }, "app/src/test/java/a/Tool.java", []);
const run = delegatedGradleConfig(test, "app", { ...ctx, offline: true })!;
expect(run.args).toContain("-Dopenidea.run.sourceSet=test");
expect(run.args).toContain("-Dopenidea.run.workingDir=/w/shop/app");
expect(run.args.slice(-2)).toEqual(["--offline", ":app:openideaRun"]);
});
it("reuses Gradle's configuration cache on Gradle 8.1+ wrappers", () => {
const app = applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []);
const run = delegatedGradleConfig(app, "app", { ...ctx, configurationCache: true })!;
expect(run.args.slice(-3)).toEqual([
"--configuration-cache",
"--configuration-cache-problems=warn",
":app:openideaRun",
]);
const props = (url: string) => `distributionBase=GRADLE_USER_HOME\ndistributionUrl=${url}\n`;
expect(wrapperGradleVersion(props("https\\://services.gradle.org/distributions/gradle-8.10.2-bin.zip"))).toEqual([
8, 10,
]);
expect(wrapperGradleVersion(props("https://x/gradle-7.6-all.zip"))).toEqual([7, 6]);
expect(wrapperGradleVersion("# no distribution\n")).toBeNull();
});
it("leaves mains outside the build's modules to the language server", () => {
const stray = applicationConfig({ className: "x.Main" }, "/elsewhere/src/main/java/x/Main.java", []);
expect(delegatedGradleConfig(stray, "/elsewhere", ctx)).toBeNull();
});
it("runs included builds' modules by identity path, never buildSrc's", () => {
const nested = {
...ctx,
gradle: {
...gradle,
projects: [
...gradle.projects,
{ path: ":lib:core", name: "core", dir: "/w/lib/core", buildFile: null, tasks: [], build: ":lib" },
{ path: ":buildSrc", name: "buildSrc", dir: "buildSrc", buildFile: null, tasks: [], build: ":buildSrc" },
],
},
};
const main = (file: string) => applicationConfig({ className: "a.Main" }, file, []);
const lib = delegatedGradleConfig(main("/w/lib/core/src/main/java/a/Main.java"), "/w/lib/core", nested)!;
expect(lib.args.at(-1)).toBe(":lib:core:openideaRun");
expect(delegatedGradleConfig(main("buildSrc/src/main/java/a/Main.java"), "buildSrc", nested)).toBeNull();
});
it("is a Gradle run from then on: never delegated twice, the Application's params dropped", () => {
const app = { ...applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []) };
const run = delegatedGradleConfig({ ...app, params: { vmOptions: "-Xmx1g" } }, "app", ctx)!;
expect(isJvmApplication(run)).toBe(false);
expect(run.params).toBeUndefined();
});
it("debugs the forked JVM: the init script adds the agent", () => {
const app = applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java", []);
const run = delegatedGradleConfig(app, "app", ctx)!;
const debug = jdwpLaunch(run)!;
expect(debug.args.at(-1)).toBe(DELEGATED_DEBUG);
expect(debug.args).not.toContain("--debug-jvm");
expect(jdwpLaunch(debug)!.args.filter((a) => a === DELEGATED_DEBUG)).toHaveLength(1);
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* IDEA's "Build and run using: Gradle": an Application / Spring Boot configuration of a
* Gradle module keeps its settings, only its launch becomes a Gradle run of a `JavaExec`
* task (`gradleRun.gradle`) on the source set's runtime classpath. Gradle compiles the
* module and its dependencies and processes resources first; the debugger attaches to
* the forked JVM (`-Dopenidea.run.debug`), not to the Gradle daemon.
*/
import RUN_SCRIPT from "./gradleRun.gradle?raw";
import { useBuildModel } from "@/platform/buildModel";
import { call, commands, isTauri, type GradleModel, type RunConfig } from "@/platform/ipc";
import { findJavaModule, isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch";
import { useProject } from "@/platform/project";
/** System property that marks a delegated run (read by `jdwpLaunch` to add the debug flag). */
export const DELEGATED_MAIN = "-Dopenidea.run.main=";
export const DELEGATED_DEBUG = "-Dopenidea.run.debug=true";
const TASK = "openideaRun";
const isAbsolute = (p: string) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
const absolute = (root: string, p: string) => (isAbsolute(p) ? p : p ? `${root}/${p}` : root);
export interface DelegationContext {
/** Project root. */
root: string;
gradle: GradleModel;
/** Absolute path of the written `gradleRun.gradle`. */
script: string;
offline: boolean;
/**
* Gradle's configuration cache: a repeated launch skips configuring the build (about half of
* Gradle's share of a warm start). Only for Gradle 8.1+, where it is stable.
*/
configurationCache: boolean;
}
/** Major / minor of the wrapper's `distributionUrl` (`gradle-8.10.2-bin.zip`), null when unknown. */
export function wrapperGradleVersion(properties: string): [number, number] | null {
const m = /^\s*distributionUrl\s*=.*gradle-(\d+)\.(\d+)/m.exec(properties);
return m ? [Number(m[1]), Number(m[2])] : null;
}
const atLeast = (v: [number, number] | null, major: number, minor: number) =>
!!v && (v[0] > major || (v[0] === major && v[1] >= minor));
/**
* The Gradle run of `config`, or null when it is not an Application of a module of this
* Gradle build (`module`: its folder, project-relative or absolute).
*/
export function delegatedGradleConfig(config: RunConfig, module: string, ctx: DelegationContext): RunConfig | null {
const parsed = parseJavaArgs(config.args);
if (!parsed.mainClass) return null;
const dir = absolute(ctx.root, module);
const project = ctx.gradle.projects.find((p) => absolute(ctx.root, p.dir) === dir);
// buildSrc tasks are not addressable from the root build; included builds' are (`:lib:core:…`).
if (!project || project.build?.endsWith(":buildSrc")) return null;
const task = project.path === ":" ? `:${TASK}` : `${project.path}:${TASK}`;
const args = [
"--console=plain",
"--init-script",
ctx.script,
`-Dopenidea.run.project=${project.path}`,
`${DELEGATED_MAIN}${parsed.mainClass}`,
`-Dopenidea.run.sourceSet=${parsed.classpath === "test" ? "test" : "main"}`,
`-Dopenidea.run.args=${JSON.stringify(parsed.programArgs)}`,
`-Dopenidea.run.jvmArgs=${JSON.stringify(parsed.vmArgs)}`,
`-Dopenidea.run.workingDir=${config.cwd ? absolute(ctx.root, config.cwd) : dir}`,
...(ctx.offline ? ["--offline"] : []),
// Problems only warn: a build with incompatible plugins still runs, uncached.
...(ctx.configurationCache ? ["--configuration-cache", "--configuration-cache-problems=warn"] : []),
task,
];
// A Gradle run from here on: not an Application again (it would be delegated twice), and
// without the Application's params (Gradle's own VM-options init script would repeat them).
const { params: _params, ...rest } = config;
return {
...rest,
kind: "gradle",
command: ctx.gradle.command,
args,
cwd: ctx.gradle.dir || null,
// The Gradle JVM's JAVA_HOME first; the configuration's own environment wins.
env: { ...ctx.gradle.env, ...config.env },
module: module,
};
}
/** Writes the init script where Gradle can read it; it never changes, so once per session. */
let scriptPath: Promise<string> | null = null;
function writeScript(): Promise<string> {
scriptPath ??= (async () => {
const { tempDir, join } = await import("@tauri-apps/api/path");
const path = await join(await tempDir(), "openidea-gradle-run.gradle");
await call(commands.fsDelete(path)).catch(() => {});
await call(commands.fsCreateFileBytes(path, Array.from(new TextEncoder().encode(RUN_SCRIPT))));
return path;
})().catch((e) => {
scriptPath = null;
throw e;
});
return scriptPath;
}
/**
* The launch of an Application / Spring Boot `config` through Gradle, when the project
* runs with Gradle (Settings › Build Tools › Gradle) and the main class is in one of its
* modules; null to launch it on the language server's classpath.
*/
export async function gradleDelegation(config: RunConfig): Promise<RunConfig | null> {
if (!isTauri() || !isJvmApplication(config)) return null;
const { project, settings } = useProject.getState();
if (!project?.root || settings?.languages?.java?.gradle?.runUsing === "ide") return null;
const { model, gradleOffline } = useBuildModel.getState();
if (!model?.gradle) return null;
const module = config.module ?? (await findJavaModule(config));
if (module === null) return null;
const ctx = {
root: project.root,
gradle: model.gradle,
script: "",
offline: gradleOffline,
configurationCache: false,
};
if (!delegatedGradleConfig(config, module, ctx)) return null;
const [script, configurationCache] = await Promise.all([writeScript(), supportsConfigurationCache(ctx)]);
return delegatedGradleConfig(config, module, { ...ctx, script, configurationCache });
}
/** The wrapper's Gradle is 8.1+ (a local or PATH Gradle's version is not known here). */
async function supportsConfigurationCache(ctx: Pick<DelegationContext, "root" | "gradle">): Promise<boolean> {
if (!/(^|[/\\])gradlew(\.bat)?$/.test(ctx.gradle.command)) return false;
const props = `${absolute(ctx.root, ctx.gradle.dir)}/gradle/wrapper/gradle-wrapper.properties`;
const text = await call(commands.fsReadText(props)).catch(() => "");
return atLeast(wrapperGradleVersion(text), 8, 1);
}
+7
View File
@@ -47,6 +47,7 @@ import { isDebugOnly, registerBuiltinTypes } from "./types";
import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate";
import { npmGutter } from "./npmGutter";
import { runGutter } from "./runGutter";
import { gradleDelegation } from "./gradleRun";
import { activateStackTrace, useStackTraces } from "./stackTrace";
import { clearRuntimeCache } from "./runtimes";
import {
@@ -119,6 +120,12 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) {
await launchProduced(compiled, true);
return;
}
// "Build and run using: Gradle": the JavaExec JVM gets the agent, the debugger attaches to it.
const delegated = getAction("debug.attach") ? await gradleDelegation(config) : null;
if (delegated) {
await debugWithAgent(delegated);
return;
}
// Maven / Gradle: the build tool starts the JVM with a debug agent, the debugger attaches.
if (jdwpLaunch(config) && getAction("debug.attach")) {
await debugWithAgent(config);
+11
View File
@@ -140,6 +140,17 @@ describe("application configurations", () => {
expect(jvmModuleDir("odd/App.java", [cfg({ id: "m", kind: "maven", cwd: "odd" })])).toBe("odd");
});
it("takes the innermost module the build model knows, like IDEA's file index", () => {
const modules = ["", "/w/billing", "/w/billing/modules/core", "legacy"];
// `sourceSets.main.java.srcDirs = ['src']`: no `src/main/java` to guess from.
expect(jvmModuleDir("legacy/src/a/App.java", [], modules)).toBe("legacy");
expect(jvmModuleDir("/w/billing/modules/core/src/main/java/a/App.java", [], modules)).toBe(
"/w/billing/modules/core",
);
// A module the model does not know yet (before the first sync): the source layout.
expect(jvmModuleDir("services/api/src/main/java/a/App.java", [], modules)).toBe("services/api");
});
it("adds JDWP to a script explicitly assigned the Kotlin debugger", () => {
const rc = cfg({ id: "kotlin-script", kind: "shell", command: "./run.sh", debugAdapter: "kotlin-debug-adapter" });
expect(jdwpLaunch(rc)?.env.JAVA_TOOL_OPTIONS).toContain("-agentlib:jdwp=");
+6 -1
View File
@@ -12,6 +12,7 @@ import { executeAction } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import { isJvmApplication, JDWP_AGENT, jdwpListeningPort } from "@/platform/javaLaunch";
import { startRun, useRun } from "./store";
import { DELEGATED_DEBUG, DELEGATED_MAIN } from "./gradleRun";
const isMavenTestGoal = (a: string) =>
/^(test|verify|integration-test|surefire:test|failsafe:integration-test)$/.test(a);
@@ -55,6 +56,9 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null {
return null;
}
if (config.kind === "gradle" || /(^|\/)gradlew?(\.bat)?$/.test(config.command)) {
// A delegated Application: its init script adds the agent (port 0) to the JavaExec JVM.
if (args.some((a) => a.startsWith(DELEGATED_MAIN)))
return args.includes(DELEGATED_DEBUG) ? config : { ...config, args: [...args, DELEGATED_DEBUG] };
if (!args.some(isGradleJvmTask)) return null;
return args.includes("--debug-jvm") ? config : { ...config, args: [...args, "--debug-jvm"] };
}
@@ -74,7 +78,8 @@ export async function debugWithAgent(config: RunConfig): Promise<boolean> {
const runId = useRun.getState().sessions.find((s) => s.id === sessionId)?.runId ?? null;
void executeAction("debug.attach", {
name: config.name,
module: config.cwd ?? "",
// The application's module (a delegated run's working directory is the Gradle build).
module: config.module ?? config.cwd ?? "",
hostName: "127.0.0.1",
port,
process: runId,
+22
View File
@@ -309,6 +309,28 @@ describe("single-file producers (scratches)", () => {
expect(p.config!.args.at(-1)).toBe("demo.Hi");
});
it("Java: a main in a Gradle module outside the project runs on the module's classpath", () => {
// `includeFlat 'billing'`: the module folder is a sibling of the project folder.
const svc = "/work/billing/modules/core";
const boot =
"package com.example;\nimport org.springframework.boot.SpringApplication;\n@SpringBootApplication\n" +
"public class ShopApplication {\n public static void main(String[] args) {\n" +
" SpringApplication.run(ShopApplication.class, args);\n }\n}";
const gradle = [cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true })];
const input = (contentRoots?: string[]) => ({
path: `${svc}/src/main/java/com/example/ShopApplication.java`,
root: ROOT,
configs: gradle,
content: boot,
line: 4,
contentRoots,
});
expect(produceContext(input(), ALL)!.config!.kind).toBe("java-file");
const p = produceContext(input(["/work/billing", svc]), ALL)!;
expect(p.config).toMatchObject({ kind: "spring-boot", module: svc, cwd: svc });
expect(p.config!.args).toEqual(["-cp", "${classpath}", "com.example.ShopApplication"]);
});
describe("Java / Kotlin mains in a build nested in a folder of repositories", () => {
// RuoYi-Vue-Plus (Maven) next to plus-ui (npm), opened together.
const boot =
+21 -7
View File
@@ -56,6 +56,12 @@ export interface ContextInput {
goModule?: (dir: string) => string | null | undefined;
/** 0-based caret line: the element at the caret wins over the file. */
line?: number;
/**
* Absolute folders outside `root` that belong to the project (IDEA's module content
* roots: `includeFlat` Gradle modules, Project Structure roots); their files run like
* the project's own, through its build.
*/
contentRoots?: readonly string[];
}
export interface Missing {
@@ -363,8 +369,14 @@ interface FileSite {
inProject: boolean;
}
/** In the project folder or one of its content roots: run through the project's build. */
function isProjectPath(input: Pick<ContextInput, "path" | "root" | "contentRoots">): boolean {
if (!input.root) return false;
return [input.root, ...(input.contentRoots ?? [])].some((r) => inProject(input.path, r));
}
function site(input: ContextInput): FileSite {
const inside = inProject(input.path, input.root);
const inside = isProjectPath(input);
const rel = relTo(input.path, input.root);
return {
idPath: rel,
@@ -809,8 +821,11 @@ export function standaloneTargets(content: string, path: string): RunTarget[] {
}
/** Run targets of a file for the caret / gutter: project-aware inside the project, standalone outside. */
export function fileTargets(input: Pick<ContextInput, "path" | "root" | "configs">, content: string): RunTarget[] {
const s = { inProject: inProject(input.path, input.root), rel: relTo(input.path, input.root) };
export function fileTargets(
input: Pick<ContextInput, "path" | "root" | "configs" | "contentRoots">,
content: string,
): RunTarget[] {
const s = { inProject: isProjectPath(input), rel: relTo(input.path, input.root) };
const rust = /\.rs$/.test(input.path);
if (/\.(c|cpp|cc|cxx)$/.test(input.path)) return standaloneTargets(content, input.path);
const cargo =
@@ -879,8 +894,7 @@ export function produceContext(input: ContextInput, env: ToolEnv): Produced | nu
*/
export function produceContextChoices(input: ContextInput, env: ToolEnv): Produced[] {
const produced = produceContext(input, env);
if (!produced?.config || produced.scope === "directory" || !inProject(input.path, input.root))
return produced ? [produced] : [];
if (!produced?.config || produced.scope === "directory" || !isProjectPath(input)) return produced ? [produced] : [];
const rel = relTo(input.path, input.root);
const cmake = cmakeTargets(input);
if (cmake.length > 1) return cmake.map((config) => ({ name: config.name, scope: produced.scope, config }));
@@ -907,7 +921,7 @@ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produc
}
function cmakeTargets(input: ContextInput): RunConfig[] {
if (!inProject(input.path, input.root)) return [];
if (!isProjectPath(input)) return [];
const lang = scriptLanguage(input.path, input.content);
if (lang !== "c" && lang !== "cpp") return [];
const rel = relTo(input.path, input.root);
@@ -921,7 +935,7 @@ const TEST_DIR = /(^|\/)(tests?|__tests__|specs?|test_?\w*)$/;
export function produceDirectory(input: ContextInput): Produced | null {
const { root } = input;
if (!root || !inProject(input.path, root) || input.path === root) return null;
if (!root || !isProjectPath(input) || input.path === root) return null;
const rel = relTo(input.path, root);
const name = baseName(rel);
const jvmRoot = /^(?:(.*)\/)?src\/test(?:\/(java|kotlin|scala|groovy))?$/.exec(rel);
+11 -12
View File
@@ -12,8 +12,15 @@ import { isVirtualUri } from "@/platform/virtualDocs";
import { shortcutLabel } from "@/platform/keymap";
import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui";
import { notifications, runExecutors } from "@/workbench/contributions";
import { choiceItems, executeContext, launchProduced, modifyContext, produceResolved } from "./contextRun";
import { goModulePath, loadGoModules } from "./goModules";
import {
choiceItems,
executeContext,
launchProduced,
modifyContext,
produceResolved,
projectContext,
} from "./contextRun";
import { loadGoModules } from "./goModules";
import { fileTargets, OPTIMISTIC, produceContextChoices, type ContextInput } from "./producers";
import { loadConfigs, useRun } from "./store";
import { targetLanguage, type RunTarget } from "./targets";
@@ -52,14 +59,7 @@ const targetsField = StateField.define<PlacedTarget[]>({
});
function targetInput(path: string, content: string, line: number): ContextInput {
return {
path,
root: useProject.getState().project?.root ?? null,
configs: useRun.getState().configs,
content,
line,
goModule: goModulePath,
};
return { ...projectContext(), path, content, line };
}
async function launch(target: RunTarget, path: string, debug: boolean, content: string) {
@@ -197,8 +197,7 @@ export function runGutter(ctx: EditorDocContext): Extension {
if (!useProject.getState().project?.root || isVirtualUri(path) || !hasGutterTargets(path)) return [];
if (/\.go$/.test(path))
void loadGoModules(useProject.getState().project?.root ?? null, useRun.getState().configs).catch(() => {});
const detect = (text: string) =>
fileTargets({ path, root: useProject.getState().project?.root ?? null, configs: useRun.getState().configs }, text);
const detect = (text: string) => fileTargets({ ...projectContext(), path }, text);
const detector = ViewPlugin.fromClass(
class {
+6 -1
View File
@@ -53,6 +53,7 @@ import { typeFor } from "./types";
import { recordRun } from "./services";
import { startedPort } from "./serverPort";
import { tomcatBrowserUrl } from "./tomcat";
import { gradleDelegation } from "./gradleRun";
import { openUrl } from "./navigate";
import { tx } from "@/platform/i18n";
@@ -661,7 +662,11 @@ export async function startRun(
};
try {
let launch = options.launch ?? config;
if (isJvmApplication(launch)) {
// "Build and run using: Gradle": the module's Gradle build compiles and launches it.
const delegated = isJvmApplication(launch) ? await gradleDelegation(launch) : null;
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (delegated) launch = delegated;
else if (isJvmApplication(launch)) {
const resolved = await resolveJvmApplication(sessionId, launch);
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (!resolved) {
@@ -318,9 +318,24 @@ export function JavaPage() {
export function GradlePage() {
const [distribution, setDistribution] = useSetting<string>("languages.java.gradle.distribution");
const [runUsing, setRunUsing] = useSetting<string>("languages.java.gradle.runUsing");
return (
<div className={styles.page}>
<Section>
<Row
label="Build and run using"
hint="Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath."
>
<Select
value={runUsing || "gradle"}
onChange={setRunUsing}
aria-label="Build and run using"
options={[
{ value: "gradle", label: "Gradle" },
{ value: "ide", label: "OpenIDEA" },
]}
/>
</Row>
<SdkRow
k="languages.java.gradle.jvm"
label="Gradle JVM"
@@ -118,6 +118,9 @@ export const EXECUTION_PAGES: PageSpec[] = [
group: BUILD_GROUP,
order: 358,
keywords: [
"Build and run using",
"delegate",
"JavaExec",
"Gradle JVM",
"JDK",
"JAVA_HOME",
+16
View File
@@ -0,0 +1,16 @@
/**
* The open project's build model as the Maven / Gradle tool windows last loaded it, for the
* features that follow the build (run, Project view) without loading it again.
*/
import { create } from "zustand";
import type { BuildModel } from "./ipc";
export interface SharedBuildModel {
/** Project the model belongs to. */
root: string | null;
model: BuildModel | null;
/** Gradle tool window's "Offline" toggle. */
gradleOffline: boolean;
}
export const useBuildModel = create<SharedBuildModel>(() => ({ root: null, model: null, gradleOffline: false }));
+13 -4
View File
@@ -6,6 +6,7 @@
*/
import { call, commands, IpcError, type JavaLaunchInfo, type RunConfig, type RequestId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { useBuildPresentation } from "@/platform/projectPresentation";
export const CLASSPATH = "${classpath}";
export const TEST_CLASSPATH = "${testClasspath}";
@@ -117,11 +118,19 @@ export function jdwpListeningPort(line: string): number | null {
}
/**
* Module directory (relative to the project root, `""` for the root) of a JVM
* source file: the part before `src/{main,test}/…`, else the working directory
* of the closest Maven / Gradle configuration.
* Module directory (relative to the project root, `""` for the root; absolute for a
* module outside it) of a JVM source file. Like IDEA's `ProjectFileIndex.getModuleForFile`:
* the innermost Maven / Gradle module folder holding the file, as the build model knows
* them (`modules`). Before a model knows a deeper one: the part before
* `src/{main,test}/…`, else the working directory of the closest Maven / Gradle configuration.
*/
export function jvmModuleDir(relPath: string, configs: readonly RunConfig[] = []): string {
export function jvmModuleDir(
relPath: string,
configs: readonly RunConfig[] = [],
modules: Iterable<string> = useBuildPresentation.getState().modules.keys(),
): string {
const owner = [...modules].filter((d) => d && relPath.startsWith(d + "/")).sort((a, b) => b.length - a.length)[0];
if (owner) return owner;
const m = /^(?:(.*)\/)?src\/(?:main|test|[\w-]+)\/(?:java|kotlin|scala|groovy)\//.exec(relPath);
if (m) return m[1] ?? "";
const cwds = configs
+10 -2
View File
@@ -80,7 +80,14 @@ export interface LanguagesValid {
jdk: string;
jdtlsJdk: string;
jdtlsVmArgs: string;
gradle: { jvm: string; distribution: "wrapper" | "local"; home: string; userHome: string };
gradle: {
jvm: string;
distribution: "wrapper" | "local";
home: string;
userHome: string;
/** IDEA's "Build and run using": Gradle (`JavaExec`) or the IDE (the language server's classpath). */
runUsing: "gradle" | "ide";
};
maven: { home: string; userSettings: string; localRepository: string; unlinkedProjects: string[] };
};
/** Preview font size in px; `null` follows the editor font size. */
@@ -124,7 +131,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
jdk: "",
jdtlsJdk: "",
jdtlsVmArgs: "",
gradle: { jvm: "", distribution: "wrapper", home: "", userHome: "" },
gradle: { jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" },
maven: { home: "", userSettings: "", localRepository: "", unlinkedProjects: [] },
},
markdown: { previewFontSize: null },
@@ -250,6 +257,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
distribution: oneOf(gradle.distribution, ["wrapper", "local"] as const, d.java.gradle.distribution),
home: text(gradle.home, d.java.gradle.home),
userHome: text(gradle.userHome, d.java.gradle.userHome),
runUsing: oneOf(gradle.runUsing, ["gradle", "ide"] as const, d.java.gradle.runUsing),
},
maven: {
home: text(maven.home, d.java.maven.home),
+3 -1
View File
@@ -5703,5 +5703,7 @@
"Usage limits": "用量限额",
"The account's 5-hour and weekly limits, when the agent reports them (Claude Code)": "账户的 5 小时和每周限额,在智能体报告时显示(Claude Code)",
"Session cost": "会话花费",
"What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)"
"What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)",
"Build and run using": "构建和运行使用",
"Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath.": "Gradle 以 JavaExec 任务在源集的运行时类路径上运行主类(包括 Gradle 的资源处理)。OpenIDEA 在 Java 语言服务器的类路径上启动它。"
}
+2
View File
@@ -896,6 +896,8 @@ export interface ProjectViewRootContribution extends ProjectViewRootEntry {
order?: number;
/** Which project view mode lists it besides "Project" ("Scratches and Consoles"). */
scope?: "scratches";
/** Listed above the project folder (IDEA's module group of the build). */
beforeProject?: boolean;
}
export const useProjectViewRoots = create<{ roots: Map<string, ProjectViewRootContribution> }>(() => ({